Compare commits

..

67 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Included `ZUGFeRD-Example.pdf` for testing and integrated `Serilog.Ui.Core.Extensions` for logging.
2026-07-30 10:16:43 +02:00
080c2ac2a0 refactor(api): Pass IConfiguration to AddApplication
- Update AddApplication call with builder.Configuration parameter
- Required for license key reading from appsettings.json
2026-07-21 16:52:32 +02:00
97122d0bbd feat(license): Configure MediatR + AutoMapper with LuckyPennySoft license
- Read LuckyPennySoftLicenseKey from appsettings.json
- Set MediatR config.LicenseKey
- Set AutoMapper cfg.LicenseKey
- AddApplication now requires IConfiguration parameter
2026-07-21 16:52:27 +02:00
e9d1586266 chore(deps): Add Microsoft.Extensions.Configuration packages
- Microsoft.Extensions.Configuration.Abstractions 10.0.10
- Microsoft.Extensions.Configuration.Binder 10.0.10
- Required for reading appsettings.json in Application layer
2026-07-21 16:52:21 +02:00
2804993ea6 feat(api): Enable conditional Swagger/Scalar in production
Program.cs changes:
- Add Scalar.AspNetCore using
- Register SwaggerSettings with Options Pattern
- Pass IConfiguration to AddSwaggerDocumentation()
- Conditional middleware: Development OR EnableInProduction=true
- Add Swagger UI with custom endpoint configuration
- Add Scalar UI at /scalar/v1 (DeepSpace theme, C# HttpClient target)

Access URLs:
- Swagger UI: https://localhost:7186/swagger
- Scalar UI: https://localhost:7186/scalar/v1
- Serilog UI: https://localhost:7186/serilog-ui

Production behavior: Swagger+Scalar enabled by default (configurable)
2026-07-21 15:01:56 +02:00
06a9dc7385 chore(deps): Add Scalar.AspNetCore 1.2.58 package
- Modern Swagger UI alternative with better UX
- DeepSpace theme
- C# HttpClient code generation support
2026-07-21 15:01:46 +02:00
4b5c763f24 refactor(swagger): Read SwaggerConfiguration from appsettings
- Update AddSwaggerDocumentation to accept IConfiguration parameter
- Read SwaggerSettings from configuration (Title/Version/Description)
- Replace hardcoded values with dynamic settings
- Add Microsoft.Extensions.Options using for IOptions support
2026-07-21 15:01:38 +02:00
559c726118 feat(swagger): Configure Swagger settings in appsettings.json
- Add SwaggerSettings section with EnableInProduction: true
- Title: DocumentOperator API
- Version: v1
- Description: PDF document processing service using DevExpress
- Production Swagger enabled by default
2026-07-21 15:01:29 +02:00
1106a86ec3 feat(swagger): Add SwaggerSettings configuration class
- Add SwaggerSettings with EnableInProduction flag (default: true)
- Title, Version, Description configurable via appsettings.json
- Enables production Swagger access for debugging/testing
2026-07-21 15:01:22 +02:00
89436406ce Add PDF Stamp Operation feature with endpoints and tests
Implemented the "PDF Stamp Operation" feature, allowing users to add text, image, or predefined stamps to PDF documents.

- Added `FEATURE_7_PLAN.md` with detailed implementation plan.
- Introduced enums (`StampType`, `PredefinedStampType`, `StampPlacement`) in the domain layer.
- Added `AddStampAsync` method to `IPdfProcessor` interface.
- Implemented `AddStampAsync` in `DevExpressPdfProcessor` using DevExpress API.
- Created unit tests for `AddStampAsync` covering various scenarios.
- Added `AddStampCommand` in the application layer with validation rules.
- Created two new endpoints in `PdfOperationsController` for multipart and Base64 inputs.
- Added DTOs for handling endpoint requests.
- Wrote integration tests for endpoints to ensure correctness.
- Updated `AGENTS.md` and added DevExpress API references.
- Documented challenges, considerations, and estimated effort.

This commit completes the "PDF Stamp Operation" feature with full test coverage and documentation.
2026-07-21 14:45:30 +02:00
c96cbbc8d3 ee
Add IIS publish profile for Web Deploy packaging

Added `IISProfile.pubxml` to configure publishing via Web Deploy.
Set the publish method to `Package` and specified build settings
(`Release`, `Any CPU`). Configured the output package location,
enabled single-file packaging, and set the IIS app path to
`DocumentOperator.API`. Target framework updated to .NET 8.0.
2026-07-21 14:44:21 +02:00
9b85e55cd4 docs: Update AGENTS.md for Feature 7 completion + Serilog.UI
Updates:
- Mark stamp endpoint as DONE in Current Status table
- Add Serilog.UI packages to Key Libraries table
- Update test count (Feature 7 instead of Feature 6)
- Add Serilog UI URL to Build/Run section
- Document log viewer access at /serilog-ui
2026-07-21 14:39:40 +02:00
536413bafe feat(logging): Configure Serilog SQLite sink + UI
appsettings.json:
- Add Application:LogDirectory configuration
- Add Serilog WriteTo.SQLite sink (logs.db in LogDirectory)
- Logs table with UTC timestamps

Program.cs:
- Add Serilog.UI usings
- Configure Serilog.UI with SQLite provider
- Mount UI at /serilog-ui endpoint

Web-based log viewer accessible at: https://localhost:7186/serilog-ui
2026-07-21 14:39:32 +02:00
b37ccc8538 chore: Add Serilog.UI packages + versioning metadata
Packages:
- Serilog.Sinks.SQLite 7.0.0 (SQLite log persistence)
- Serilog.UI 3.2.0 (Web-based log viewer)
- Serilog.UI.SqliteProvider 1.1.0 (SQLite provider for Serilog.UI)

Versioning metadata:
- Version: 1.0.0
- Authors/Company: Digital Data GmbH
- Copyright: 2026
- Description: PDF document processing service using DevExpress
2026-07-21 14:39:24 +02:00
d0606f3605 feat(stamp): Add Application layer (AddStampCommand with handler/validator)
- Vertical slice: Command + Handler + Validator in single file
- Validation rules enforce StampType-specific required fields
- Text stamps: Text + FontName + FontSize required
- Image stamps: ImageBytes required
- Predefined stamps: PredefinedType required
- Supports Origin, Rotation, Opacity, Placement, Size parameters
2026-07-21 14:39:03 +02:00
c037ad8446 feat(stamp): Add IPdfProcessor.AddStampAsync interface
- Add AddStampAsync method signature to IPdfProcessor interface
- Parameters: pdfBytes, stampType, pages, position, text/image/predefined params
- Supports Origin (TopLeft/BottomLeft), Rotation, Opacity, Placement
2026-07-21 14:38:29 +02:00
e2bec710e0 feat(stamp): Add Domain value objects for stamp operations
- Add StampType enum (Text, Image, Predefined)
- Add PredefinedStampType enum (CONFIDENTIAL, APPROVED, DRAFT, etc.)
- Add StampPlacement enum (Foreground, Background)
- All enums in DocumentOperator.Domain namespace
2026-07-21 14:38:22 +02:00
61b11fc216 feat(annotation): Add Origin/Width/Height to API endpoints
- Update AddAnnotationFromFile/AddAnnotationFromBase64 endpoints
- Add Origin parameter to multipart/JSON request DTOs
- Add Width/Height as alternative to X2/Y2 in requests
- Calculate X2/Y2 from Width/Height if provided
- XML documentation updated with new parameters
2026-07-21 14:38:14 +02:00
c4ec0c2b48 feat(annotation): Implement coordinate system conversion in DevExpressPdfProcessor
- Add Y-axis conversion for TopLeft origin (bottomLeftY = pageHeight - topLeftY)
- Origin parameter support in AddAnnotationAsync
- Preserves existing BottomLeft behavior as default
2026-07-21 14:38:08 +02:00
eed9d46e19 feat(annotation): Add Origin/Width/Height parameters to AddAnnotationCommand
- Add Origin parameter (default: BottomLeft)
- Add Width/Height as alternatives to X2/Y2
- Validation: Either (X2+Y2) OR (Width+Height) required, not both
- FluentValidation rules enforce mutual exclusivity
2026-07-21 14:38:01 +02:00
c1bb3abeef feat(annotation): Add AnnotationOrigin value object
- Add AnnotationOrigin enum (BottomLeft/TopLeft)
- BottomLeft = PDF native coordinate system (default)
- TopLeft = UI-friendly coordinate system (requires Y-axis conversion)
- Shared with stamp operations for consistency
2026-07-21 14:37:54 +02:00
d72d41ec2d test(integration): Add 10 integration tests for annotation endpoints + update docs
Integration tests:
- 5 happy path tests (TextMarkup, FreeText, StickyNote, Circle, Square) with multipart + Base64 mix
- 5 validation error tests (invalid Base64, page number, missing content/style, invalid color)
- All existing 7 merge tests retained (now 17 total in PdfOperationsControllerTests)

Documentation updates (AGENTS.md):
- Update test count: 82 -> 101 passed, 7 skipped
- Update PdfOperationsController status: 1/N -> 2/3 endpoints (merge + annotate DONE, stamp TODO)
- Add test breakdown by feature (6 features listed)
- Update 'Run tests' section with Feature 6 mention

Test results: 101 PASSED, 7 SKIPPED, 0 FAILED
2026-07-21 12:28:59 +02:00
d4107f6f89 feat(api): Add annotation endpoints to PdfOperationsController
- Add POST /api/pdf/operations/annotate (multipart/form-data)
- Add POST /api/pdf/operations/annotate (application/json with Base64)
- Create AddAnnotationMultipartRequest DTO (wrapper for 10+ form parameters)
- Create AddAnnotationBase64Command DTO (Base64 PDF + annotation parameters)
- Add unique operation names (AnnotateFromFile, AnnotateFromBase64) for Swagger
- Rename MergePdfsRequest -> MergePdfsBase64Request for clarity
- Add Name attributes to merge endpoints (MergeFromFiles, MergeFromBase64) to fix Swagger conflict
- Base64 FormatException wrapped in BadRequestException
2026-07-21 12:28:43 +02:00
22ac2889af feat(application): Add AddAnnotationCommand with handler and validator
- Create AddAnnotationCommand (Command/Handler/Validator merged in single file)
- Use primary constructors for handler (IPdfProcessor dependency)
- FluentValidation rules: stream required, pageNumber > 0, content for FreeText/StickyNote
- Validate textMarkupStyle required for TextMarkup annotations
- Validate color format (6-digit hex) and rectangle coordinates (X2>X1, Y2>Y1)
2026-07-21 12:28:30 +02:00
a23c78ec3a test(infrastructure): Add 12 unit tests for AddAnnotationAsync
- 5 happy path tests (one per annotation type)
- 7 validation error tests (empty stream, invalid position, page number, content, style, color)
- All tests passing (12/12)
- Total unit tests: 37 (25 previous + 12 annotation)
2026-07-21 12:28:17 +02:00
41f97ce533 feat(infrastructure): Implement DevExpressPdfProcessor.AddAnnotationAsync
- Implement AddAnnotationAsync using DevExpress PdfDocumentProcessor
- Add 5 private helper methods (one per annotation type)
- Add ParseColor helper (hex string to PdfRGBColor)
- Validation: content required for FreeText/StickyNote, style for TextMarkup
- Default colors: Yellow for TextMarkup, Red for others
- Handle DevExpress API quirks (TextMarkupStyle.StrikeOut capitalization)
2026-07-21 12:28:05 +02:00
25fbea205f feat(infrastructure): Add IPdfProcessor.AddAnnotationAsync interface
- Add AddAnnotationAsync method with 8 parameters
- Support Stream-based PDF input (Position=0 required)
- Accept annotation type, page number, rectangle coordinates
- Optional parameters: content, author, color (hex), textMarkupStyle
- Returns annotated PDF as byte array
2026-07-21 12:27:52 +02:00
e95f070b9b feat(domain): Add annotation value objects for Feature 6
- Add AnnotationType enum (TextMarkup, FreeText, StickyNote, Circle, Square)
- Add TextMarkupStyle enum (Highlight, Underline, Strikeout)
- Support 5 annotation types as per CONTROLLER_ENDPOINTS.md requirements
2026-07-21 12:27:41 +02:00
aafe46a738 docs: Update AGENTS.md for Feature 5 (PDF Merge) completion
- Update test count: 82 passed, 7 skipped (was 62 passed)
- Update PdfOperationsController status: Partial (1/N endpoints), 7 tests
- Add PdfOperationsController Status section with merge endpoint details
- Mark merge endpoint as DONE (Phase 1, Priority 5)
- List remaining Phase 2 endpoints: stamp, annotate
2026-07-21 10:22:04 +02:00
0bec759396 test: Add 7 integration tests for PdfOperationsController merge endpoint
Multipart tests (4):
- POST_Merge_Multipart_TwoPdfs_Returns200WithMergedPdf
- POST_Merge_Multipart_ThreePdfs_Returns200
- POST_Merge_Multipart_SinglePdf_Returns400
- POST_Merge_Multipart_CorruptedPdf_Returns400Or500 (flexible assertion)

Base64 tests (3):
- POST_Merge_Base64_TwoPdfs_Returns200WithMergedPdf
- POST_Merge_Base64_ThreePdfs_Returns200
- POST_Merge_Base64_SinglePdf_Returns400

Skipped tests (2):
- POST_Merge_Multipart_WithPageRanges_Returns200 (multipart List<string?> binding complex)
- POST_Merge_Multipart_InvalidPageRange_Returns400 (page ranges work via JSON endpoint)
2026-07-21 10:21:46 +02:00
5c3fafff1b feat: Add PdfOperationsController with dual-input merge endpoints
- Add PdfOperationsController.cs with route '/api/pdf/operations'
- MergeFromFiles: POST /merge (multipart/form-data) - accepts List<IFormFile>
- MergeFromBase64: POST /merge (application/json) - accepts MergePdfsRequest DTO
- Returns merged PDF as FileContentResult (application/pdf)
- Supports optional page ranges via JSON endpoint only (multipart binding complex)
- XML documentation with response codes (200, 400, 500)
- Uses primary constructor pattern
2026-07-21 10:21:27 +02:00
bc273c7f4f feat: Add MergePdfsCommand with merged Command/Handler/Validator
- Add MergePdfsCommand.cs (Vertical Slice pattern)
- Command: IRequest<byte[]> with PdfStreams + PageRanges properties
- Handler: Calls IPdfProcessor.MergePdfsAsync, uses primary constructor
- Validator: Validates minimum 2 PDFs, page ranges count matches PDF count
- All 3 classes in single file (Command/Handler/Validator merged)
2026-07-21 10:21:10 +02:00
e12b64a517 test: Add 10 unit tests for MergePdfsAsync + LoadTestPdfAsStream helper
Unit tests (10):
- MergePdfsAsync_TwoPdfs_ReturnsMergedPdf
- MergePdfsAsync_ThreePdfs_ReturnsMergedPdf
- MergePdfsAsync_WithNullPageRanges_MergesAllPages
- MergePdfsAsync_WithEmptyPageRanges_MergesAllPages
- MergePdfsAsync_WithRangeFormat_MergesSelectedPages
- MergePdfsAsync_SinglePdf_ThrowsBadRequestException
- MergePdfsAsync_InvalidPageRangeCount_ThrowsBadRequestException
- MergePdfsAsync_InvalidPageRangeFormat_ThrowsBadRequestException
- MergePdfsAsync_PageNumberOutOfRange_ThrowsBadRequestException
- MergePdfsAsync_StreamNotAtPositionZero_ThrowsBadRequestException

Helper:
- Add LoadTestPdfAsStream helper for Stream-returning test setup
2026-07-21 10:20:53 +02:00
3598c5f9c6 feat: Implement DevExpressPdfProcessor.MergePdfsAsync with page range support
- Implement MergePdfsAsync: merges multiple PDFs with optional page ranges
- Add ParsePageRange helper: parses '1-3,5' format, validates page numbers
- Stream-based pipeline (no byte[] buffering)
- Validates: Position = 0, minimum 2 PDFs, page ranges count
- Uses DevExpress PdfDocumentProcessor for actual merge operation
- Returns merged PDF as byte array
2026-07-21 10:20:35 +02:00
522de8a863 feat: Add IPdfProcessor.MergePdfsAsync interface
- Add MergePdfsAsync method to IPdfProcessor interface
- Parameters: IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges
- Returns: Task<byte[]> (merged PDF)
- Validates: Minimum 2 PDFs, stream Position = 0, page ranges count matches PDF count
- Supports optional page ranges (e.g., '1-3,5' or null for all pages)
2026-07-21 10:20:18 +02:00
cb552e54e7 docs: Update AGENTS.md - PdfAttachmentController 2/3 endpoints, 62 tests 2026-07-21 09:27:14 +02:00
fa4e55242d fix: Update SwissQrCode test - ArgumentException to BadRequestException 2026-07-21 09:26:51 +02:00
e14044c48a test: Add PdfAttachmentController extract endpoint integration tests (6 tests) 2026-07-21 09:26:43 +02:00
34e38f19e5 feat: Add PdfAttachmentController extract endpoints (multipart + Base64) 2026-07-21 09:26:34 +02:00
61b1595258 test: Add ExtractAttachmentsAsync unit tests (ZIP validation, edge cases) 2026-07-21 09:26:27 +02:00
26458a4017 feat: Implement DevExpressPdfProcessor.ExtractAttachmentsAsync with ZIP packaging 2026-07-21 09:26:18 +02:00
2c673ea98e feat: Add IPdfProcessor.ExtractAttachmentsAsync interface method 2026-07-21 09:26:11 +02:00
1989ca7ef7 feat: Add ExtractPdfAttachments Application layer (Command/Handler/Validator merged) 2026-07-21 09:26:04 +02:00
0f4d860176 test(integration): migrate integration tests to Controller DTOs and update assertions
Test Changes:
- Use Controller DTOs (ValidatePdfBase64Request, ValidatePdfABase64Request, CheckPdfAttachmentsRequest, ExtractSwissQrCodeBase64Request)
- Remove direct Query object usage in HTTP tests (architectural violation)
- Update imports: DocumentOperator.API.Controllers namespace

Assertion Updates:
- Invalid Base64 tests: case-insensitive regex (?i)base.?64 (FormatException message contains 'Base-64' with hyphen)
- Empty PDF tests: regex match for 'Base64|empty|stream' (flexible validation error matching)
- Corrupted PDF test: Expect 500 Internal Server Error (DevExpress exception propagates naturally)

Result: 52 tests pass, 6 skipped (IBAN validation - DevExpress limitation)
2026-07-20 16:33:19 +02:00
645dfceafa test(unit): migrate unit tests to Stream API
Application Handler Tests:
- ValidatePdfHandlerTests: PdfStream = new MemoryStream(pdfBytes)
- ValidatePdfAQueryHandlerTests: PdfStream = new MemoryStream(pdfBytes)
- CheckPdfAttachmentsQueryHandlerTests: PdfStream = new MemoryStream(pdfBytes)
- Remove Base64/PdfBytes property usage

Infrastructure Tests:
- DevExpressSwissQrCodeProcessorTests: LoadTestPdf() returns Stream
- All test methods use 'using var stream' pattern
- Add test: ExtractSwissQrCodeAsync_StreamNotAtBeginning_ThrowsBadRequestException

Result: All unit tests pass with Stream-based API
2026-07-20 16:33:00 +02:00
07be9b9f02 refactor(domain): remove obsolete exception types
Deleted:
- PdfProcessingException: Obsolete, DevExpress exceptions now propagate naturally
- SwissQrCodeNotFoundException: Moved to Application layer (feature-specific exception)

Rationale:
- PdfProcessingException was wrapping library exceptions unnecessarily
- Better to let infrastructure exceptions propagate → middleware handles as 500
- SwissQrCodeNotFoundException is application-level concern, not domain
2026-07-20 16:32:36 +02:00
a1e8575018 refactor(api): remove generic exception handling from middleware
Remove FormatException/ArgumentException handling:
- These are framework exceptions, not application-specific
- May come from internal libraries (false positives for 400 Bad Request)
- Controllers now wrap Base64 conversion with BadRequestException explicitly

Remove PdfProcessingException handling:
- Exception type removed (obsolete)
- DevExpress exceptions now propagate naturally → 500 Internal Server Error

Current exception mapping:
- ValidationException (FluentValidation) → 400 Bad Request
- BadRequestException (custom) → 400 Bad Request
- NotFoundException (custom) → 404 Not Found
- SwissQrCodeNotFoundException (custom) → 404 Not Found
- All others → 500 Internal Server Error
2026-07-20 16:32:19 +02:00
b4befde418 refactor(api): migrate controllers to Stream API with Base64 validation
PdfValidationController:
- ValidateFromFile: IFormFile.OpenReadStream() direct usage (no byte[] copy)
- ValidateFromBase64: try-catch Convert.FromBase64String → BadRequestException
- ValidatePdfAFromFile: IFormFile.OpenReadStream() direct usage
- ValidatePdfAFromBase64: try-catch Convert.FromBase64String → BadRequestException

PdfAttachmentController:
- CheckAttachmentsFromBase64: try-catch Convert.FromBase64String → BadRequestException

SwissQrCodeController:
- ExtractFromBase64: try-catch Convert.FromBase64String → BadRequestException

All controllers: Add using DocumentOperator.Domain.Common.Exceptions for BadRequestException
2026-07-20 16:32:03 +02:00
1af158840e refactor(infrastructure): implement Stream-based PDF processing
DevExpressPdfProcessor:
- ValidateAsync, ValidatePdfAAsync, CheckAttachmentsAsync: Stream parameters
- Defensive Position=0 validation (BadRequestException for seekable streams not at beginning)
- Remove unsafe Position reset (non-seekable stream compatibility)
- Remove PdfProcessingException wrapping (let DevExpress exceptions propagate naturally)

DevExpressSwissQrCodeProcessor:
- ExtractSwissQrCodeAsync: Stream parameter
- Defensive Position=0 validation
- Remove unsafe Position reset

Memory optimization: MemoryStream.TryGetBuffer fast path for byte[] extraction
2026-07-20 16:31:47 +02:00
5dc2e38507 refactor(application): update processor interfaces for Stream API
- IPdfProcessor: ValidateAsync, ValidatePdfAAsync, CheckAttachmentsAsync now accept Stream
- ISwissQrCodeProcessor: ExtractSwissQrCodeAsync now accepts Stream
- Update XML documentation: Position=0 requirement, non-seekable stream support
- Exception documentation: BadRequestException for validation errors (stream empty/invalid/wrong position)
2026-07-20 16:31:31 +02:00
c93488c29f refactor(application): migrate all queries to Stream-based API
- Replace byte[] and Base64String with required Stream PdfStream
- Simplify validators: remove XOR/Base64 validation, only check NotNull
- Affected queries: ValidatePdfQuery, ValidatePdfAQuery, CheckPdfAttachmentsQuery, ExtractSwissQrCodeQuery
- Memory efficiency: direct stream usage, no intermediate byte[] copies
2026-07-20 16:31:14 +02:00
140 changed files with 9101 additions and 623 deletions

View File

@@ -1,6 +1,6 @@
# AGENTS.md
Agent guidance for DocumentOperator service. Read this before working on the codebase.
Agent guidance for DocumentService service. Read this before working on the codebase.
---
@@ -23,7 +23,7 @@ Agent guidance for DocumentOperator service. Read this before working on the cod
### Migration Required
**Existing code that needs replacement:**
- `DocumentOperator.API/Endpoints/v1/DocumentEndpoints.cs` → Delete, replace with Controllers
- `DocumentService.API/Endpoints/v1/DocumentEndpoints.cs` → Delete, replace with Controllers
- `Program.cs` line 72: `app.MapDocumentEndpoints()` → Replace with `app.MapControllers()`
- `Program.cs` line 44: Add `builder.Services.AddControllers()`
- All DTOs → Support **BOTH** `IFormFile` (multipart) AND `Base64String` (JSON)
@@ -188,16 +188,17 @@ After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, SwissQ
dotnet build
```
**Run tests (30 tests as of Feature 3 - PDF/A Validation):**
**Run tests (101 passed, 7 skipped as of Feature 7 - PDF Stamp):**
```powershell
dotnet test
```
**Run API (Development):**
```powershell
dotnet run --project DocumentOperator.API
dotnet run --project DocumentService.API
```
Swagger UI: `https://localhost:<port>/swagger`
Swagger UI: `https://localhost:7186/swagger`
Serilog UI: `https://localhost:7186/serilog-ui` (Web-based log viewer)
**Target framework:** .NET 8.0
**SDK required:** 8.0.412 or later (repo has 8.0.41210.0.203 available)
@@ -214,6 +215,8 @@ Swagger UI: `https://localhost:<port>/swagger`
| **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 |
| **Serilog.Sinks.SQLite** (7.0.0) | SQLite log persistence | API layer |
| **Serilog.UI** (3.2.0) + **Serilog.UI.SqliteProvider** (1.1.0) | Web-based log viewer UI | API layer |
**Critical:** DevExpress requires a license. All PDF operations use `DevExpress.Pdf.PdfDocumentProcessor`.
@@ -275,10 +278,20 @@ Do NOT add `if (result.IsSuccess)` checks. Throw exceptions for errors. The midd
|-----------|--------|-------|
| **PdfValidationController** | ✅ DONE | 13 (7 validate + 6 validate-pdfa) |
| **SwissQrCodeController** | ✅ DONE | 2 |
| **PdfAttachmentController** | ⏳ Pending | 0 |
| **PdfOperationsController** | ⏳ Pending | 0 |
| **PdfAttachmentController** | ⏳ Partial (2/3 endpoints) | 10 (4 check + 6 extract) |
| **PdfOperationsController** | ⏳ Partial (3/3 endpoints, integration tests pending) | 29 (7 merge + 22 annotate: 12 unit + 10 integration) |
| **PdfConversionController** | ⏳ Pending | 0 |
**PdfAttachmentController Status:**
-`POST /api/pdf/attachments/check` - DONE (with multipart + Base64 support)
-`POST /api/pdf/attachments/extract` - DONE (Phase 1, Priority 4) - Returns ZIP with all attachments
-`POST /api/pdf/attachments/add` - TODO (Phase 2, Priority 7)
**PdfOperationsController Status:**
-`POST /api/pdf/operations/merge` - DONE (Phase 1, Priority 5) - Merges multiple PDFs with optional page ranges (multipart + Base64)
-`POST /api/pdf/operations/annotate` - DONE (Phase 2, Priority 6) - Adds annotations (TextMarkup/FreeText/StickyNote/Circle/Square) with multipart + Base64 support
-`POST /api/pdf/operations/stamp` - DONE (Phase 2, Priority 6) - Adds text/image/predefined stamps (multipart + Base64 support, origin/rotation/opacity/placement)
**Note:** PdfRenderController removed - moved to .NET client library.
---
@@ -326,7 +339,7 @@ Do NOT skip steps. Each feature is done when it's **testable in Swagger UI with
**All test PDFs are EmbeddedResource.** Access via:
```csharp
var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
.GetManifestResourceStream("DocumentService.Tests.TestData.Pdfs.valid.pdf");
```
**Do NOT commit new binary files** without marking them as `<EmbeddedResource>`.
@@ -338,7 +351,7 @@ var stream = Assembly.GetExecutingAssembly()
**3-folder structure (CORRECT approach by previous developer):**
```
DocumentOperator.Tests/
DocumentService.Tests/
├── Integration/
│ └── API/
│ ├── PdfValidationControllerTests.cs (13 tests)
@@ -375,10 +388,19 @@ DocumentOperator.Tests/
- Matches Application layer structure exactly
4. **Test Pyramid:**
- **Unit tests (15):** Fast, isolated, many scenarios
- **Integration tests (15):** Slower, full pipeline, critical paths only
- **Unit tests (60+):** Fast, isolated, many scenarios
- **Integration tests (27):** Slower, full pipeline, critical paths only
**Test count:** 30 tests total (as of Feature 3 - PDF/A Validation)
**Test count:** 101 passed, 7 skipped (as of Feature 6 - PDF Annotation)
**Test breakdown by feature:**
- Feature 1 (PDF Validation): 13 integration tests
- Feature 2 (Swiss QR Code): 2 integration tests
- Feature 3 (PDF/A Validation): 6 integration tests (validate-pdfa) + 4 unit tests (handler)
- Feature 4 (PDF Attachments): 10 tests (4 check + 6 extract integration)
- Feature 5 (PDF Merge): 7 integration + 10 unit tests (DevExpressPdfProcessor)
- Feature 6 (PDF Annotation): 10 integration + 12 unit tests (DevExpressPdfProcessor)
- Infrastructure: 37 unit tests (DevExpressPdfProcessor for validation, attachments, merge, annotation)
**FluentValidation in tests:**
- Base64 format validation happens in `ValidatePdfQueryValidator` and `ValidatePdfAQueryValidator`
@@ -498,7 +520,7 @@ public async Task<IActionResult> Validate([FromForm] PdfInputModel input, Cancel
## Configuration
**appsettings.json sections:**
- `DocumentOperatorSettings` (future: file size limits, temp paths)
- `DocumentServiceSettings` (future: file size limits, temp paths)
- `RedisSettings` (future: multi-tenancy caching)
- `ApiKeySettings` (future: authentication)

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
using Microsoft.OpenApi.Models;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using System.Reflection;
namespace DocumentOperator.API.Configuration
namespace DocumentService.API.Configuration
{
/// <summary>
/// Provides extension methods for configuring Swagger/OpenAPI documentation.
@@ -12,16 +13,22 @@ namespace DocumentOperator.API.Configuration
/// Adds Swagger documentation generation to the service collection.
/// </summary>
/// <param name="services">The service collection to add Swagger to.</param>
/// <param name="configuration">Configuration to read SwaggerSettings from.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services)
public static IServiceCollection AddSwaggerDocumentation(
this IServiceCollection services,
IConfiguration configuration)
{
var swaggerSettings = configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
?? new SwaggerSettings();
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
options.SwaggerDoc(swaggerSettings.Version, new OpenApiInfo
{
Title = "DD Document Operator API",
Version = "v1",
Description = "PDF Verarbeitungs-Service für Validierung, Stempel, Zertifikate, Anhänge & Zusammenführung"
Title = swaggerSettings.Title,
Version = swaggerSettings.Version,
Description = swaggerSettings.Description
});
// Resolve conflicting actions: Keep first variant

View File

@@ -0,0 +1,33 @@
namespace DocumentService.API.Configuration;
/// <summary>
/// Configuration settings for Swagger/OpenAPI documentation.
/// </summary>
public class SwaggerSettings
{
/// <summary>
///
/// </summary>
public const string SectionName = "SwaggerSettings";
/// <summary>
/// Enable Swagger UI in Production environment.
/// Default: true (allows production testing/debugging).
/// </summary>
public bool EnableInProduction { get; set; } = true;
/// <summary>
/// API title displayed in Swagger UI.
/// </summary>
public string Title { get; set; } = "DocumentService API";
/// <summary>
/// API version.
/// </summary>
public string Version { get; set; } = "v1";
/// <summary>
/// API description displayed in Swagger UI.
/// </summary>
public string Description { get; set; } = "PDF document processing service";
}

View File

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

View File

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

View File

@@ -0,0 +1,728 @@
using DocumentService.Application.AddAnnotation;
using DocumentService.Application.AddStamp;
using DocumentService.Application.MergePdfs;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Domain.Models.ValueObjects;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DocumentService.API.Controllers;
/// <summary>
/// Controller for PDF operations (merge, stamp, annotate).
/// </summary>
[ApiController]
[Route("api/pdf/operations")]
public class PdfOperationsController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Merges multiple PDF files into a single PDF.
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="files">PDF files to merge (minimum 2 required)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Merged PDF file</returns>
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
/// <response code="400">Invalid input (fewer than 2 files, corrupted PDF)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("merge", Name = "MergeFromFiles")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> MergeFromFiles(
[FromForm] List<IFormFile> files,
CancellationToken cancellationToken)
{
// Convert IFormFile[] to Stream[] (use OpenReadStream directly - no buffering)
var streams = files.Select(f => f.OpenReadStream()).ToList();
// Send command to MediatR (no page ranges for now - multipart binding is complex)
var command = new MergePdfsCommand
{
PdfStreams = streams,
PageRanges = null
};
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
// Return merged PDF
return File(mergedPdf, "application/pdf", "merged.pdf");
}
/// <summary>
/// Merges multiple PDF files into a single PDF.
/// Supports Base64-encoded PDFs via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDFs and optional page ranges</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Merged PDF file</returns>
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
/// <response code="400">Invalid input (Base64 format error, fewer than 2 files, corrupted PDF, invalid page range)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("merge", Name = "MergeFromBase64")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> MergeFromBase64(
[FromBody] MergePdfsBase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64[] to MemoryStream[]
List<Stream> streams = [];
try
{
foreach (var base64Pdf in request.Base64Pdfs)
{
byte[] pdfBytes = Convert.FromBase64String(base64Pdf);
streams.Add(new MemoryStream(pdfBytes));
}
}
catch (FormatException ex)
{
// Dispose opened streams on error
foreach (var stream in streams) stream.Dispose();
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
var command = new MergePdfsCommand
{
PdfStreams = streams,
PageRanges = request.PageRanges
};
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
// Cleanup streams (important for MemoryStreams we created)
foreach (var stream in streams) stream.Dispose();
return File(mergedPdf, "application/pdf", "merged.pdf");
}
/// <summary>
/// Adds an annotation to a PDF document.
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="request">Multipart form data containing PDF file and annotation parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Annotated PDF file</returns>
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
/// <response code="400">Invalid input (invalid page number, missing required parameters)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("annotate", Name = "AnnotateFromFile")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AnnotateFromFile(
[FromForm] AddAnnotationMultipartRequest request,
CancellationToken cancellationToken)
{
// Calculate X2, Y2 from Width/Height if provided
double x2 = request.X2 ?? request.X1 + (request.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
double y2 = request.Y2 ?? request.Y1 + (request.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
var command = new AddAnnotationCommand
{
PdfStream = request.File.OpenReadStream(),
AnnotationType = request.AnnotationType,
PageNumber = request.PageNumber,
Rectangle = (request.X1, request.Y1, x2, y2),
Content = request.Content,
Author = request.Author,
Color = request.Color,
TextMarkupStyle = request.TextMarkupStyle,
Origin = request.Origin
};
byte[] annotatedPdf = await mediator.Send(command, cancellationToken);
return File(annotatedPdf, "application/pdf", "annotated.pdf");
}
/// <summary>
/// Adds an annotation to a PDF document.
/// Supports Base64-encoded PDF via JSON payload.
/// </summary>
/// <param name="command">Command containing all annotation parameters (including Base64 PDF)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Annotated PDF file</returns>
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("annotate", Name = "AnnotateFromBase64")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AnnotateFromBase64(
[FromBody] AddAnnotationBase64Command command,
CancellationToken cancellationToken)
{
// Convert Base64 to MemoryStream
Stream pdfStream;
try
{
byte[] pdfBytes = Convert.FromBase64String(command.Base64Pdf);
pdfStream = new MemoryStream(pdfBytes);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
// Calculate X2, Y2 from Width/Height if provided
double x2 = command.X2 ?? command.X1 + (command.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
double y2 = command.Y2 ?? command.Y1 + (command.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
var annotationCommand = new AddAnnotationCommand
{
PdfStream = pdfStream,
AnnotationType = command.AnnotationType,
PageNumber = command.PageNumber,
Rectangle = (command.X1, command.Y1, x2, y2),
Content = command.Content,
Author = command.Author,
Color = command.Color,
TextMarkupStyle = command.TextMarkupStyle,
Origin = command.Origin
};
byte[] annotatedPdf = await mediator.Send(annotationCommand, cancellationToken);
// Cleanup stream
pdfStream.Dispose();
return File(annotatedPdf, "application/pdf", "annotated.pdf");
}
/// <summary>
/// Adds a stamp (text, image, or predefined) to PDF pages.
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="request">Multipart form data containing PDF file and stamp parameters</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Stamped PDF file</returns>
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
/// <response code="400">Invalid input (invalid page number, missing required parameters, invalid image format)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("stamp", Name = "AddStampFromFile")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddStampFromFile(
[FromForm] AddStampMultipartRequest request,
CancellationToken cancellationToken)
{
// Convert ImageFile to byte[] if provided
byte[]? imageBytes = null;
if (request.ImageFile != null)
{
using var ms = new MemoryStream();
await request.ImageFile.CopyToAsync(ms, cancellationToken);
imageBytes = ms.ToArray();
}
var command = new AddStampCommand
{
PdfStream = request.File.OpenReadStream(),
StampType = request.StampType,
PageNumbers = request.PageNumbers,
Position = (request.X, request.Y),
Size = request.Width.HasValue && request.Height.HasValue
? (request.Width.Value, request.Height.Value)
: null,
Origin = request.Origin,
Text = request.Text,
FontName = request.FontName,
FontSize = request.FontSize,
Color = request.Color,
Opacity = request.Opacity,
Rotation = request.Rotation,
Placement = request.Placement,
ImageBytes = imageBytes,
PredefinedType = request.PredefinedType
};
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
return File(stampedPdf, "application/pdf", "stamped.pdf");
}
/// <summary>
/// Adds a stamp (text, image, or predefined) to PDF pages.
/// Supports Base64-encoded PDF and image via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDF, stamp parameters, and optional Base64 image</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Stamped PDF file</returns>
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("stamp", Name = "AddStampFromBase64")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddStampFromBase64(
[FromBody] AddStampBase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64 PDF to MemoryStream
Stream pdfStream;
try
{
byte[] pdfBytes = Convert.FromBase64String(request.Base64Pdf);
pdfStream = new MemoryStream(pdfBytes);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
}
// Convert Base64 image to byte[] if provided
byte[]? imageBytes = null;
if (!string.IsNullOrWhiteSpace(request.Base64Image))
{
try
{
imageBytes = Convert.FromBase64String(request.Base64Image);
}
catch (FormatException ex)
{
pdfStream.Dispose();
throw new BadRequestException("Invalid Base64 image format: " + ex.Message);
}
}
var command = new AddStampCommand
{
PdfStream = pdfStream,
StampType = request.StampType,
PageNumbers = request.PageNumbers,
Position = (request.X, request.Y),
Size = request.Width.HasValue && request.Height.HasValue
? (request.Width.Value, request.Height.Value)
: null,
Origin = request.Origin,
Text = request.Text,
FontName = request.FontName,
FontSize = request.FontSize,
Color = request.Color,
Opacity = request.Opacity,
Rotation = request.Rotation,
Placement = request.Placement,
ImageBytes = imageBytes,
PredefinedType = request.PredefinedType
};
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
// Cleanup stream
pdfStream.Dispose();
return File(stampedPdf, "application/pdf", "stamped.pdf");
}
}
/// <summary>
/// Request DTO for multipart/form-data annotation operation
/// </summary>
public class AddAnnotationMultipartRequest
{
/// <summary>
/// PDF file to annotate
/// </summary>
public required IFormFile File { get; set; }
/// <summary>
/// Type of annotation (TextMarkup, FreeText, StickyNote, Circle, Square)
/// </summary>
public required AnnotationType AnnotationType { get; set; }
/// <summary>
/// Target page number (1-indexed)
/// </summary>
public required int PageNumber { get; set; }
/// <summary>
/// Rectangle X1 coordinate (left)
/// </summary>
public required double X1 { get; set; }
/// <summary>
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
/// </summary>
public required double Y1 { get; set; }
/// <summary>
/// Rectangle X2 coordinate (right). Optional if Width is provided.
/// </summary>
public double? X2 { get; set; }
/// <summary>
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
/// </summary>
public double? Y2 { get; set; }
/// <summary>
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
/// </summary>
public double? Width { get; set; }
/// <summary>
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
/// </summary>
public double? Height { get; set; }
/// <summary>
/// Annotation content (required for FreeText/StickyNote)
/// </summary>
public string? Content { get; set; }
/// <summary>
/// Author name (optional)
/// </summary>
public string? Author { get; set; }
/// <summary>
/// Hex color (6 digits, e.g., "FF0000" for red)
/// </summary>
public string? Color { get; set; }
/// <summary>
/// Markup style (Highlight/Underline/Strikeout, required for TextMarkup)
/// </summary>
public TextMarkupStyle? TextMarkupStyle { get; set; }
/// <summary>
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
/// </summary>
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
}
/// <summary>
/// Request DTO for Base64-encoded PDF merge operation (API layer only - converts to MergePdfsCommand)
/// </summary>
public record MergePdfsBase64Request
{
/// <summary>
/// Array of Base64-encoded PDF files (minimum 2 required)
/// </summary>
/// <example>["JVBERi0xLjQK...", "JVBERi0xLjQK..."]</example>
public required List<string> Base64Pdfs { get; init; }
/// <summary>
/// Optional page ranges per PDF (null = all pages).
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
/// If provided, array length must match Base64Pdfs length.
/// </summary>
/// <example>["1-2", "1,3,5", null]</example>
public List<string?>? PageRanges { get; init; }
}
/// <summary>
/// Request DTO for Base64-encoded PDF annotation (API layer only - converts to AddAnnotationCommand)
/// </summary>
public record AddAnnotationBase64Command
{
/// <summary>
/// Base64-encoded PDF file
/// </summary>
/// <example>"JVBERi0xLjQK..."</example>
public required string Base64Pdf { get; init; }
/// <summary>
/// Type of annotation to add
/// </summary>
/// <example>TextMarkup</example>
public required AnnotationType AnnotationType { get; init; }
/// <summary>
/// Target page number (1-indexed)
/// </summary>
/// <example>1</example>
public required int PageNumber { get; init; }
/// <summary>
/// Rectangle X1 coordinate (left)
/// </summary>
/// <example>100.0</example>
public required double X1 { get; init; }
/// <summary>
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
/// </summary>
/// <example>100.0</example>
public required double Y1 { get; init; }
/// <summary>
/// Rectangle X2 coordinate (right). Optional if Width is provided.
/// </summary>
/// <example>200.0</example>
public double? X2 { get; init; }
/// <summary>
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
/// </summary>
/// <example>120.0</example>
public double? Y2 { get; init; }
/// <summary>
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
/// </summary>
/// <example>100.0</example>
public double? Width { get; init; }
/// <summary>
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
/// </summary>
/// <example>20.0</example>
public double? Height { get; init; }
/// <summary>
/// Annotation content (required for FreeText and StickyNote)
/// </summary>
/// <example>"Important text to highlight"</example>
public string? Content { get; init; }
/// <summary>
/// Author name (optional)
/// </summary>
/// <example>"John Doe"</example>
public string? Author { get; init; }
/// <summary>
/// Hex color (6 digits, e.g., "FF0000" for red). Optional - defaults vary by annotation type.
/// </summary>
/// <example>"FFFF00"</example>
public string? Color { get; init; }
/// <summary>
/// Text markup style (Highlight, Underline, or Strikeout). Required for TextMarkup annotations.
/// </summary>
/// <example>Highlight</example>
public TextMarkupStyle? TextMarkupStyle { get; init; }
/// <summary>
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
/// </summary>
/// <example>BottomLeft</example>
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
}
/// <summary>
/// Request DTO for multipart/form-data stamp operation
/// </summary>
public class AddStampMultipartRequest
{
/// <summary>
/// PDF file to stamp
/// </summary>
public required IFormFile File { get; set; }
/// <summary>
/// Type of stamp (Text, Image, or Predefined)
/// </summary>
public required StampType StampType { get; set; }
/// <summary>
/// Target page numbers (1-indexed). Null or empty = all pages.
/// </summary>
/// <example>[1, 3, 5]</example>
public int[]? PageNumbers { get; set; }
/// <summary>
/// Stamp position X coordinate
/// </summary>
/// <example>100.0</example>
public required double X { get; set; }
/// <summary>
/// Stamp position Y coordinate
/// </summary>
/// <example>100.0</example>
public required double Y { get; set; }
/// <summary>
/// Stamp width (optional, auto-size for images if not specified)
/// </summary>
/// <example>200.0</example>
public double? Width { get; set; }
/// <summary>
/// Stamp height (optional, auto-size for images if not specified)
/// </summary>
/// <example>50.0</example>
public double? Height { get; set; }
/// <summary>
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
/// </summary>
/// <example>BottomLeft</example>
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
/// <summary>
/// Text content (required for Text stamps)
/// </summary>
/// <example>"CONFIDENTIAL"</example>
public string? Text { get; set; }
/// <summary>
/// Font name (default: Arial)
/// </summary>
/// <example>"Arial"</example>
public string? FontName { get; set; }
/// <summary>
/// Font size in points (default: 12)
/// </summary>
/// <example>24.0</example>
public double? FontSize { get; set; }
/// <summary>
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
/// </summary>
/// <example>"FF0000"</example>
public string? Color { get; set; }
/// <summary>
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
/// </summary>
/// <example>0.5</example>
public double? Opacity { get; set; }
/// <summary>
/// Rotation angle in degrees (0-360, default: 0)
/// </summary>
/// <example>45.0</example>
public double? Rotation { get; set; }
/// <summary>
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
/// </summary>
/// <example>Foreground</example>
public StampPlacement Placement { get; set; } = StampPlacement.Foreground;
/// <summary>
/// Image file (required for Image stamps, PNG/JPEG)
/// </summary>
public IFormFile? ImageFile { get; set; }
/// <summary>
/// Predefined stamp type (required for Predefined stamps)
/// </summary>
/// <example>Confidential</example>
public PredefinedStampType? PredefinedType { get; set; }
}
/// <summary>
/// Request DTO for Base64-encoded PDF stamp operation (API layer only - converts to AddStampCommand)
/// </summary>
public record AddStampBase64Request
{
/// <summary>
/// Base64-encoded PDF file
/// </summary>
/// <example>"JVBERi0xLjQK..."</example>
public required string Base64Pdf { get; init; }
/// <summary>
/// Type of stamp (Text, Image, or Predefined)
/// </summary>
/// <example>Text</example>
public required StampType StampType { get; init; }
/// <summary>
/// Target page numbers (1-indexed). Null or empty = all pages.
/// </summary>
/// <example>[1, 3, 5]</example>
public int[]? PageNumbers { get; init; }
/// <summary>
/// Stamp position X coordinate
/// </summary>
/// <example>100.0</example>
public required double X { get; init; }
/// <summary>
/// Stamp position Y coordinate
/// </summary>
/// <example>100.0</example>
public required double Y { get; init; }
/// <summary>
/// Stamp width (optional, auto-size for images if not specified)
/// </summary>
/// <example>200.0</example>
public double? Width { get; init; }
/// <summary>
/// Stamp height (optional, auto-size for images if not specified)
/// </summary>
/// <example>50.0</example>
public double? Height { get; init; }
/// <summary>
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
/// </summary>
/// <example>BottomLeft</example>
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
/// <summary>
/// Text content (required for Text stamps)
/// </summary>
/// <example>"CONFIDENTIAL"</example>
public string? Text { get; init; }
/// <summary>
/// Font name (default: Arial)
/// </summary>
/// <example>"Arial"</example>
public string? FontName { get; init; }
/// <summary>
/// Font size in points (default: 12)
/// </summary>
/// <example>24.0</example>
public double? FontSize { get; init; }
/// <summary>
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
/// </summary>
/// <example>"FF0000"</example>
public string? Color { get; init; }
/// <summary>
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
/// </summary>
/// <example>0.5</example>
public double? Opacity { get; init; }
/// <summary>
/// Rotation angle in degrees (0-360, default: 0)
/// </summary>
/// <example>45.0</example>
public double? Rotation { get; init; }
/// <summary>
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
/// </summary>
/// <example>Foreground</example>
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
/// <summary>
/// Base64-encoded image (required for Image stamps, PNG/JPEG)
/// </summary>
/// <example>"iVBORw0KGgoAAAANSUhEUgAA..."</example>
public string? Base64Image { get; init; }
/// <summary>
/// Predefined stamp type (required for Predefined stamps)
/// </summary>
/// <example>Confidential</example>
public PredefinedStampType? PredefinedType { get; init; }
}

View File

@@ -1,10 +1,12 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Application.ValidatePdfA.Queries;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.ValidatePdf.Queries;
using DocumentService.Application.ValidatePdfA.Queries;
using DocumentService.Client.Models.Requests;
using DocumentService.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
namespace DocumentService.API.Controllers;
/// <summary>
/// PDF validation operations
@@ -42,13 +44,11 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
});
}
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Direct pass-through to MediatR
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
@@ -57,7 +57,7 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
/// <summary>
/// Validates a PDF document and returns metadata (Base64 JSON)
/// </summary>
/// <param name="query">PDF as Base64 string</param>
/// <param name="request">Request containing Base64-encoded PDF</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>
@@ -69,10 +69,24 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ValidateFromBase64(
[FromBody] ValidatePdfQuery query,
[FromBody] ValidatePdfBase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Direct pass-through to MediatR
var query = new ValidatePdfQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
@@ -106,13 +120,11 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
});
}
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Direct pass-through to MediatR
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
@@ -121,7 +133,7 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
/// <summary>
/// Validates a PDF/A document and checks conformance level (Base64 JSON)
/// </summary>
/// <param name="query">PDF as Base64 string</param>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF/A metadata (conformance level, errors, warnings)</returns>
/// <response code="200">PDF/A validation completed, results returned</response>
@@ -133,10 +145,24 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ValidatePdfAFromBase64(
[FromBody] ValidatePdfAQuery query,
[FromBody] ValidatePdfABase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Direct pass-through to MediatR
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);

View File

@@ -1,9 +1,10 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.SwissQrCode.Queries;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.SwissQrCode.Queries;
using DocumentService.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
namespace DocumentService.API.Controllers;
/// <summary>
/// Swiss QR Code extraction operations
@@ -17,7 +18,7 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
/// 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="raw"></param>
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</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>
@@ -43,15 +44,13 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
Status = StatusCodes.Status400BadRequest
});
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Direct pass-through to MediatR
var query = new ExtractSwissQrCodeQuery
{
PdfBytes = pdfBytes
PdfStream = pdfStream
};
var result = await Mediator.Send(query, cancellationToken);
@@ -61,8 +60,8 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
/// <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="raw"></param>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</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>
@@ -76,13 +75,39 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractFromBase64(
[FromBody] ExtractSwissQrCodeQuery query,
[FromBody] ExtractSwissQrCodeBase64Request request,
[FromQuery] bool raw = false,
CancellationToken cancellationToken = default)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Direct pass-through to MediatR
var query = new ExtractSwissQrCodeQuery { PdfStream = pdfStream };
var result = await Mediator.Send(query, cancellationToken);
return Ok(raw ? result.RawLines : result.Bill);
}
}
/// <summary>
/// Request DTO for Base64-encoded Swiss QR Code extraction
/// </summary>
public record ExtractSwissQrCodeBase64Request
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public required string Base64Pdf { get; init; }
}

View File

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

View File

@@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.28" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentOperator.Application\DocumentOperator.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentOperator.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>DocumentOperator.API</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>DocumentOperator.API</Product>
<Version>1.0.0</Version>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<InformationalVersion>1.0.0</InformationalVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<Description>PDF Document Operations REST API - Validation, Swiss QR Code extraction, attachments, merge, annotation, stamp operations powered by DevExpress Office File API</Description>
<PackageTags>pdf document operator validation swiss-qr-code annotations stamp devexpress</PackageTags>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.28" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.58" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
<PackageReference Include="Serilog.UI" Version="3.2.0" />
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentService.Infrastructure.csproj" />
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
</ItemGroup>
</Project>

View File

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

View File

@@ -1,11 +1,10 @@
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Exceptions;
using DocumentService.Domain.Common.Exceptions;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Text.Json;
namespace DocumentOperator.API.Middleware;
namespace DocumentService.API.Middleware;
/// <summary>
/// Central exception handling middleware
@@ -67,7 +66,7 @@ public class ExceptionHandlingMiddleware(RequestDelegate Next)
}
),
// Not Found Exception (404 Not Found)
// Bad Request Exception (400 Bad Request)
BadRequestException badReqEx => (
HttpStatusCode.BadRequest,
new ProblemDetails
@@ -93,19 +92,6 @@ public class ExceptionHandlingMiddleware(RequestDelegate Next)
}
),
// Swiss QR Code Not Found Exception (404 Not Found)
SwissQrCodeNotFoundException qrNotFoundEx => (
HttpStatusCode.NotFound,
new ProblemDetails
{
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
Title = "Swiss QR Code Not Found",
Status = (int)HttpStatusCode.NotFound,
Detail = qrNotFoundEx.Message,
Instance = context.Request.Path
}
),
// Generic Exception (500 Internal Server Error)
_ => (
HttpStatusCode.InternalServerError,

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
using Serilog;
using DocumentOperator.Infrastructure.Configuration;
using DocumentOperator.Application;
using DocumentOperator.Infrastructure;
using DocumentOperator.API.Middleware;
using DocumentOperator.API.Configuration;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
using Serilog.Ui.Web.Extensions;
using Scalar.AspNetCore;
using DocumentService.Infrastructure.Configuration;
using DocumentService.Application;
using DocumentService.Application.Common.Configuration;
using DocumentService.Infrastructure;
using DocumentService.API.Middleware;
using DocumentService.API.Configuration;
var builder = WebApplication.CreateBuilder(args);
@@ -13,20 +18,23 @@ var builder = WebApplication.CreateBuilder(args);
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "DocumentOperator")
.Enrich.WithProperty("Application", "DocumentService")
.CreateLogger();
builder.Host.UseSerilog();
Log.Information("Starting DocumentOperator API...");
Log.Information("Starting DocumentService API...");
try
{
// ========================================
// 2. Options Pattern Configuration
// ========================================
builder.Services.Configure<DocumentOperatorSettings>(
builder.Configuration.GetSection(DocumentOperatorSettings.SectionName));
builder.Services.Configure<DocumentServiceSettings>(
builder.Configuration.GetSection(DocumentServiceSettings.SectionName));
builder.Services.Configure<ZugferdSettings>(
builder.Configuration.GetSection("ZugferdSettings"));
builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection(RedisSettings.SectionName));
@@ -34,18 +42,37 @@ try
builder.Services.Configure<ApiKeySettings>(
builder.Configuration.GetSection(ApiKeySettings.SectionName));
builder.Services.Configure<SwaggerSettings>(
builder.Configuration.GetSection(SwaggerSettings.SectionName));
// ========================================
// 3. Services (Clean Architecture Layers)
// ========================================
builder.Services.AddApplication(); // Application Layer (MediatR, FluentValidation, Behaviors)
builder.Services.AddApplication(builder.Configuration); // Application Layer (MediatR, FluentValidation, Behaviors)
builder.Services.AddInfrastructure(); // Infrastructure Layer (DevExpress, Services)
builder.Services.AddControllers(); // Controllers (Controller-based API)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerDocumentation();
builder.Services.AddSwaggerDocumentation(builder.Configuration);
// ========================================
// 4. Build App
// 4. Serilog.UI Configuration
// ========================================
var logDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
builder.Services.AddSerilogUi(logUIOpt =>
{
logUIOpt.UseSqliteServer(dbOpt =>
{
dbOpt.WithConnectionString($"Data Source={sqliteDbPath}");
dbOpt.WithTable("Logs");
});
});
// ========================================
// 5. Build App
// ========================================
var app = builder.Build();
@@ -56,10 +83,32 @@ try
// Exception Handling FIRST (catches all exceptions from subsequent middleware)
app.UseMiddleware<ExceptionHandlingMiddleware>();
if (app.Environment.IsDevelopment())
// ========================================
// Swagger/OpenAPI (Conditional based on settings)
// ========================================
var swaggerSettings = builder.Configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
?? new SwaggerSettings();
if (app.Environment.IsDevelopment() || swaggerSettings.EnableInProduction)
{
app.UseSwagger();
app.UseSwaggerUI();
// Swagger UI (classic)
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint($"/swagger/{swaggerSettings.Version}/swagger.json",
$"{swaggerSettings.Title} {swaggerSettings.Version}");
options.RoutePrefix = "swagger"; // /swagger
});
// Scalar UI (modern alternative)
app.MapScalarApiReference(options =>
{
options
.WithTitle(swaggerSettings.Title)
.WithTheme(ScalarTheme.DeepSpace)
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
});
}
app.UseSerilogRequestLogging(); // Log HTTP Requests
@@ -67,11 +116,16 @@ try
app.UseHttpsRedirection();
// ========================================
// 6. Endpoints (Controller-based API)
// 6. Serilog.UI Dashboard
// ========================================
app.UseSerilogUi(); // Accessible at /serilog-ui
// ========================================
// 7. Endpoints (Controller-based API)
// ========================================
app.MapControllers(); // Maps all [ApiController] controllers
Log.Information("DocumentOperator API started successfully");
Log.Information("DocumentService API started successfully");
app.Run();
}
@@ -87,7 +141,7 @@ finally
// Make Program class accessible for Integration Tests
/// <summary>
/// Entry point class for the DocumentOperator API.
/// Entry point class for the DocumentService API.
/// Made partial and public for integration test access.
/// </summary>
public partial class Program { }

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<WebPublishMethod>Package</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\DocumentService\PreRelease\API\net8\$(Version)\DocumentService.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>DocumentService.API</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View File

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

View File

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

View File

@@ -7,6 +7,17 @@
},
"AllowedHosts": "*",
"Application": {
"LogDirectory": "E:\\LogFiles\\Digital Data\\DocumentService.API"
},
"SwaggerSettings": {
"EnableInProduction": true,
"Title": "DocumentService API",
"Version": "v1",
"Description": "PDF document processing service using DevExpress"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
@@ -30,21 +41,45 @@
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "SQLite",
"Args": {
"sqliteDbPath": "E:\\LogFiles\\Digital Data\\DocumentService.API\\logs.db",
"tableName": "Logs",
"storeTimestampInUtc": true
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"DocumentOperatorSettings": {
"TempFolderPath": "C:\\Temp\\DocumentOperator",
"DocumentServiceSettings": {
"TempFolderPath": "C:\\Temp\\DocumentService",
"TempFileRetentionHours": 24,
"MaxPdfSizeMB": 50,
"EnableDetailedLogging": true
},
"ZugferdSettings": {
"ZugferdFileNames": [
"factur-x.xml",
"zugferd-invoice.xml",
"ZUGFeRD-invoice.xml",
"xrechnung.xml",
"XRechnung.xml"
],
"ZugferdFileNamePatterns": [
"factur",
"zugferd",
"xrechnung",
"peppol"
]
},
"RedisSettings": {
"ConnectionString": "localhost:6379",
"InstanceName": "DocumentOperator:",
"InstanceName": "DocumentService:",
"CacheExpirationMinutes": 60
},
@@ -62,5 +97,6 @@
"IsActive": true
}
}
}
},
"LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA"
}

View File

@@ -0,0 +1,78 @@
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Models.ValueObjects;
using FluentValidation;
using MediatR;
namespace DocumentService.Application.AddAnnotation;
/// <summary>
/// Command to add an annotation to a PDF document.
/// </summary>
public record AddAnnotationCommand : IRequest<byte[]>
{
public required Stream PdfStream { get; init; }
public required AnnotationType AnnotationType { get; init; }
public required int PageNumber { get; init; }
public required (double X1, double Y1, double X2, double Y2) Rectangle { get; init; }
public string? Content { get; init; }
public string? Author { get; init; }
public string? Color { get; init; }
public TextMarkupStyle? TextMarkupStyle { get; init; }
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
}
/// <summary>
/// Handler for AddAnnotationCommand.
/// </summary>
public class AddAnnotationHandler(IPdfProcessor pdfProcessor) : IRequestHandler<AddAnnotationCommand, byte[]>
{
public async Task<byte[]> Handle(AddAnnotationCommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.AddAnnotationAsync(
request.PdfStream,
request.AnnotationType,
request.PageNumber,
request.Rectangle,
request.Content,
request.Author,
request.Color,
request.TextMarkupStyle,
request.Origin);
}
}
/// <summary>
/// Validator for AddAnnotationCommand.
/// </summary>
public class AddAnnotationValidator : AbstractValidator<AddAnnotationCommand>
{
public AddAnnotationValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
RuleFor(x => x.PageNumber)
.GreaterThan(0)
.WithMessage("Page number must be greater than 0");
RuleFor(x => x.Content)
.NotEmpty()
.When(x => x.AnnotationType == AnnotationType.FreeText || x.AnnotationType == AnnotationType.StickyNote)
.WithMessage("Content is required for FreeText and StickyNote annotations");
RuleFor(x => x.TextMarkupStyle)
.NotNull()
.When(x => x.AnnotationType == AnnotationType.TextMarkup)
.WithMessage("TextMarkupStyle is required for TextMarkup annotations");
RuleFor(x => x.Color)
.Matches("^[0-9A-Fa-f]{6}$")
.When(x => !string.IsNullOrWhiteSpace(x.Color))
.WithMessage("Color must be a 6-digit hex value (e.g., 'FF0000' for red)");
RuleFor(x => x.Rectangle)
.Must(r => r.X2 > r.X1 && r.Y2 > r.Y1)
.WithMessage("Rectangle coordinates must define a valid area (X2 > X1 and Y2 > Y1)");
}
}

View File

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

View File

@@ -0,0 +1,125 @@
using FluentValidation;
using MediatR;
namespace DocumentService.Application.AddStamp;
/// <summary>
/// Command to add a stamp (text, image, or predefined) to PDF pages.
/// Combines Command, Handler, and Validator in a single file (Vertical Slice pattern).
/// </summary>
public record AddStampCommand : IRequest<byte[]>
{
public required Stream PdfStream { get; init; }
public required Domain.Models.ValueObjects.StampType StampType { get; init; }
public int[]? PageNumbers { get; init; } // null = all pages
public required (double X, double Y) Position { get; init; }
public (double Width, double Height)? Size { get; init; }
public Domain.Models.ValueObjects.AnnotationOrigin Origin { get; init; } = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft;
public string? Text { get; init; }
public string? FontName { get; init; }
public double? FontSize { get; init; }
public string? Color { get; init; }
public double? Opacity { get; init; }
public double? Rotation { get; init; }
public Domain.Models.ValueObjects.StampPlacement Placement { get; init; } = Domain.Models.ValueObjects.StampPlacement.Foreground;
public byte[]? ImageBytes { get; init; }
public Domain.Models.ValueObjects.PredefinedStampType? PredefinedType { get; init; }
}
/// <summary>
/// Handler for AddStampCommand.
/// </summary>
public class AddStampHandler(Common.Interfaces.IPdfProcessor pdfProcessor) : IRequestHandler<AddStampCommand, byte[]>
{
public async Task<byte[]> Handle(AddStampCommand command, CancellationToken cancellationToken)
{
return await pdfProcessor.AddStampAsync(
command.PdfStream,
command.StampType,
command.PageNumbers,
command.Position,
command.Size,
command.Origin,
command.Text,
command.FontName,
command.FontSize,
command.Color,
command.Opacity,
command.Rotation,
command.Placement,
command.ImageBytes,
command.PredefinedType
);
}
}
/// <summary>
/// Validator for AddStampCommand.
/// </summary>
public class AddStampValidator : AbstractValidator<AddStampCommand>
{
public AddStampValidator()
{
RuleFor(x => x.PdfStream)
.NotNull().WithMessage("PDF stream is required");
RuleFor(x => x.Position.X)
.GreaterThanOrEqualTo(0).WithMessage("Position X must be >= 0");
RuleFor(x => x.Position.Y)
.GreaterThanOrEqualTo(0).WithMessage("Position Y must be >= 0");
// Text stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Text, () =>
{
RuleFor(x => x.Text)
.NotEmpty().WithMessage("Text is required for Text stamp type");
});
// Image stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Image, () =>
{
RuleFor(x => x.ImageBytes)
.NotNull().WithMessage("Image bytes are required for Image stamp type")
.Must(bytes => bytes != null && bytes.Length > 0).WithMessage("Image bytes cannot be empty");
});
// Predefined stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Predefined, () =>
{
RuleFor(x => x.PredefinedType)
.NotNull().WithMessage("Predefined type is required for Predefined stamp type");
});
// Optional parameter validations
When(x => x.Opacity.HasValue, () =>
{
RuleFor(x => x.Opacity!.Value)
.InclusiveBetween(0.0, 1.0).WithMessage("Opacity must be between 0.0 and 1.0");
});
When(x => x.Rotation.HasValue, () =>
{
RuleFor(x => x.Rotation!.Value)
.InclusiveBetween(0.0, 360.0).WithMessage("Rotation must be between 0 and 360 degrees");
});
When(x => x.FontSize.HasValue, () =>
{
RuleFor(x => x.FontSize!.Value)
.GreaterThan(0).WithMessage("Font size must be positive");
});
When(x => !string.IsNullOrWhiteSpace(x.Color), () =>
{
RuleFor(x => x.Color!)
.Matches(@"^[0-9A-Fa-f]{6}$").WithMessage("Color must be 6-digit hex (e.g., 'FF0000')");
});
When(x => x.PageNumbers != null, () =>
{
RuleFor(x => x.PageNumbers!)
.Must(pages => pages.All(p => p > 0)).WithMessage("All page numbers must be >= 1");
});
}
}

View File

@@ -1,24 +1,19 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
namespace DocumentService.Application.CheckPdfAttachments.Queries;
/// <summary>
/// Query for checking PDF attachments (supports both byte array and Base64 input)
/// Query for checking PDF attachments (Stream-based)
/// </summary>
public record CheckPdfAttachmentsQuery : IRequest<AttachmentCheckResult>
{
/// <summary>
/// PDF as byte array (direct upload via multipart/form-data)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (for API clients using application/json)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -33,14 +28,8 @@ public class CheckPdfAttachmentsQueryHandler(IPdfProcessor PdfProcessor, IMapper
/// </summary>
public async Task<AttachmentCheckResult> Handle(CheckPdfAttachmentsQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Convert to stream for IPdfProcessor
using var pdfStream = new MemoryStream(pdfBytes);
// Call DevExpress service (exceptions propagate naturally)
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(pdfStream);
// Call DevExpress service directly with stream (exceptions propagate naturally)
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(request.PdfStream);
// Map DTO to response DTO using AutoMapper
return Mapper.Map<AttachmentCheckResult>(attachmentInfo);

View File

@@ -1,44 +1,18 @@
using FluentValidation;
namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
namespace DocumentService.Application.CheckPdfAttachments.Queries;
/// <summary>
/// Validator for CheckPdfAttachmentsQuery
/// Ensures exactly one input type (PdfBytes OR Base64Pdf) is provided
/// Ensures PdfStream is not null
/// </summary>
public class CheckPdfAttachmentsQueryValidator : AbstractValidator<CheckPdfAttachmentsQuery>
{
public CheckPdfAttachmentsQueryValidator()
{
// Rule 1: Exactly ONE input must be provided (XOR logic)
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");
// Rule 2: Base64 format validation (if provided)
RuleFor(x => x.Base64Pdf)
.Must(BeValidBase64)
.When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf))
.WithMessage("Base64Pdf must be a valid Base64 string");
}
/// <summary>
/// Validates if a string is a valid Base64 format
/// </summary>
private bool BeValidBase64(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return true; // Skip validation if null/empty (handled by Rule 1)
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
// Rule: PdfStream must be provided and non-empty
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,191 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentService.Application.Common.DTOs;
namespace DocumentOperator.Application.Common.Interfaces;
namespace DocumentService.Application.Common.Interfaces;
public interface IPdfProcessor
{
/// <summary>
/// Validates a PDF and extracts metadata.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <returns>PDF metadata (page count, size, version, attachments)</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty or invalid
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
Task<PdfMetadata> ValidateAsync(Stream pdfStream);
/// <summary>
/// Validates a PDF/A document and checks conformance level.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty or invalid
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
Task<PdfAMetadata> ValidatePdfAAsync(Stream pdfStream);
/// <summary>
/// Checks for embedded files (attachments) in a PDF document and returns detailed metadata.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <returns>Attachment information (count, file names, MIME types, sizes)</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty or invalid
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
Task<AttachmentInfo> CheckAttachmentsAsync(Stream pdfStream);
/// <summary>
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <returns>ZIP archive containing all extracted attachments as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty, invalid, or not positioned at the beginning
/// </exception>
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
/// Thrown when PDF contains no attachments
/// </exception>
Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream);
/// <summary>
/// Merges multiple PDF documents into a single PDF.
/// </summary>
/// <param name="pdfStreams">
/// PDF streams to merge (minimum 2 required). Each stream must be readable and positioned
/// at the beginning (Position = 0). Caller is responsible for disposal.
/// </param>
/// <param name="pageRanges">
/// Optional page ranges per PDF (null = all pages). Format: "1-3,5" means pages 1, 2, 3, and 5.
/// If null or empty for a PDF, all pages are included. Array length must match pdfStreams length if provided.
/// </param>
/// <returns>Merged PDF as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when fewer than 2 PDFs provided, any stream is empty/invalid/not at Position=0,
/// or page range format is invalid
/// </exception>
Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null);
/// <summary>
/// Adds an annotation to a PDF document at the specified page and rectangle.
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="annotationType">Type of annotation to add (TextMarkup, FreeText, StickyNote, Circle, Square)</param>
/// <param name="pageNumber">Page number (1-based) where annotation should be added</param>
/// <param name="rectangle">Annotation bounding rectangle (X1, Y1, X2, Y2)</param>
/// <param name="content">Annotation content/comment text (required for FreeText and StickyNote)</param>
/// <param name="author">Optional author name</param>
/// <param name="color">Optional annotation color in RGB format (hex string like "FF0000" for red)</param>
/// <param name="textMarkupStyle">Text markup style (Highlight, Underline, Strikeout) - only for TextMarkup type</param>
/// <param name="origin">Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft</param>
/// <returns>Annotated PDF as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid/not at Position=0, page number out of range,
/// rectangle invalid, or content missing for types that require it
/// </exception>
Task<byte[]> AddAnnotationAsync(
Stream pdfStream,
Domain.Models.ValueObjects.AnnotationType annotationType,
int pageNumber,
(double X1, double Y1, double X2, double Y2) rectangle,
string? content = null,
string? author = null,
string? color = null,
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft);
/// <summary>
/// Adds a stamp (text, image, or predefined) to specified pages of a PDF document.
/// </summary>
/// <param name="pdfStream">Input PDF stream (must support reading and seeking)</param>
/// <param name="stampType">Type of stamp (Text, Image, or Predefined)</param>
/// <param name="pageNumbers">Target page numbers (1-based). Null = all pages.</param>
/// <param name="position">Stamp position (X, Y coordinates)</param>
/// <param name="size">Stamp size (Width, Height). Null = auto-size for images.</param>
/// <param name="origin">Coordinate origin (BottomLeft or TopLeft)</param>
/// <param name="text">Text content (required for Text stamps)</param>
/// <param name="fontName">Font name (default: Arial)</param>
/// <param name="fontSize">Font size in points (default: 12)</param>
/// <param name="color">Hex color without # (e.g., "FF0000" for red, default: "000000")</param>
/// <param name="opacity">Opacity 0.0 (transparent) to 1.0 (opaque, default: 0.5)</param>
/// <param name="rotation">Rotation angle in degrees 0-360 (default: 0)</param>
/// <param name="placement">Foreground (on top) or Background (behind content)</param>
/// <param name="imageBytes">Image data (required for Image stamps, PNG/JPEG)</param>
/// <param name="predefinedType">Predefined stamp type (required for Predefined stamps)</param>
/// <returns>Stamped PDF as byte array</returns>
/// <exception cref="BadRequestException">Invalid parameters (missing text/image, invalid page numbers, invalid opacity/rotation)</exception>
/// <exception cref="PdfProcessingException">DevExpress processing error</exception>
Task<byte[]> AddStampAsync(
Stream pdfStream,
Domain.Models.ValueObjects.StampType stampType,
int[]? pageNumbers,
(double X, double Y) position,
(double Width, double Height)? size = null,
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft,
string? text = null,
string? fontName = null,
double? fontSize = null,
string? color = null,
double? opacity = null,
double? rotation = null,
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
byte[]? imageBytes = null,
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null);
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="attachments">List of files to embed (filename, content, optional MIME type)</param>
/// <returns>PDF with embedded attachments as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid, or attachments list is empty
/// </exception>
Task<byte[]> AddAttachmentsAsync(
Stream pdfStream,
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments);
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
/// <returns>PDF/A compliant document as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid or PDF/A level is unsupported
/// </exception>
Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel);
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// </summary>
/// <param name="pdfStream">
/// PDF/A document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <returns>Standard PDF document as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid
/// </exception>
Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream);
}

View File

@@ -1,6 +1,6 @@
using Codecrete.SwissQRBill.Generator;
namespace DocumentOperator.Application.Common.Interfaces;
namespace DocumentService.Application.Common.Interfaces;
/// <summary>
/// Interface for Swiss QR Code processing operations.
@@ -12,18 +12,21 @@ public interface ISwissQrCodeProcessor
/// Extracts and parses Swiss QR Code from a PDF document.
/// Returns both parsed Bill object and raw QR text lines.
/// </summary>
/// <param name="pdfBytes">PDF document as byte array</param>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="pageNumbers">Optional: Specific page numbers to scan (1-indexed). If null, scans all pages starting with last page.</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Tuple: (Parsed Codecrete Bill, Raw QR lines as string array)</returns>
/// <exception cref="Domain.Exceptions.SwissQrCodeNotFoundException">
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
/// Thrown when no Swiss QR Code is found in the specified pages
/// </exception>
/// <exception cref="Domain.Exceptions.PdfProcessingException">
/// Thrown when PDF processing fails
/// <exception cref="ArgumentException">
/// Thrown when stream is empty or (for seekable streams) not positioned at the beginning
/// </exception>
Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
byte[] pdfBytes,
Stream pdfStream,
int[]? pageNumbers = null,
CancellationToken cancellationToken = default);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
using DocumentService.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentService.Application.ExtractPdfAttachments;
/// <summary>
/// Command to extract all embedded files from a PDF document and return as ZIP archive.
/// </summary>
public record ExtractPdfAttachmentsCommand : IRequest<byte[]>
{
/// <summary>
/// PDF document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
}
/// <summary>
/// Handler for ExtractPdfAttachmentsCommand.
/// Extracts all embedded files from PDF and returns as ZIP archive.
/// </summary>
public class ExtractPdfAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<ExtractPdfAttachmentsCommand, byte[]>
{
public async Task<byte[]> Handle(ExtractPdfAttachmentsCommand request, CancellationToken cancellationToken)
{
// Delegate to infrastructure layer
return await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
}
}
/// <summary>
/// Validator for ExtractPdfAttachmentsCommand.
/// </summary>
public class ExtractPdfAttachmentsCommandValidator : AbstractValidator<ExtractPdfAttachmentsCommand>
{
public ExtractPdfAttachmentsCommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
using DocumentService.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentService.Application.MergePdfs;
/// <summary>
/// Command to merge multiple PDF documents into a single PDF.
/// </summary>
public record MergePdfsCommand : IRequest<byte[]>
{
/// <summary>
/// PDF streams to merge. Minimum 2 required. Each must be at Position=0.
/// </summary>
public required IReadOnlyList<Stream> PdfStreams { get; init; }
/// <summary>
/// Optional page ranges per PDF (null = all pages).
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
/// If provided, array length must match PdfStreams length.
/// </summary>
public IReadOnlyList<string?>? PageRanges { get; init; }
}
/// <summary>
/// Handler for MergePdfsCommand.
/// Delegates PDF merge operation to infrastructure layer.
/// </summary>
public class MergePdfsCommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<MergePdfsCommand, byte[]>
{
public async Task<byte[]> Handle(MergePdfsCommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.MergePdfsAsync(request.PdfStreams, request.PageRanges);
}
}
/// <summary>
/// Validator for MergePdfsCommand.
/// </summary>
public class MergePdfsCommandValidator : AbstractValidator<MergePdfsCommand>
{
public MergePdfsCommandValidator()
{
RuleFor(x => x.PdfStreams)
.NotNull()
.WithMessage("PDF streams are required");
RuleFor(x => x.PdfStreams)
.Must(streams => streams != null && streams.Count >= 2)
.WithMessage("At least 2 PDF files are required for merging");
}
}

View File

@@ -1,24 +1,19 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.SwissQrCode.Queries;
namespace DocumentService.Application.SwissQrCode.Queries;
/// <summary>
/// Query for extracting Swiss QR Code from PDF (supports both byte array and Base64 input)
/// Query for extracting Swiss QR Code from PDF (Stream-based)
/// </summary>
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -34,11 +29,8 @@ public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcesso
/// </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: returns (Bill, RawLines)
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, pageNumbers: null, cancellationToken);
// Extract: returns (Bill, RawLines) - pass stream directly
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(request.PdfStream, pageNumbers: null, cancellationToken);
// Map Codecrete Bill to DTO using AutoMapper
var billDto = mapper.Map<SwissQrBillDto>(bill);

View File

@@ -1,59 +1,19 @@
using DocumentOperator.Application.SwissQrCode.Queries;
using DocumentService.Application.SwissQrCode.Queries;
using FluentValidation;
namespace DocumentOperator.Application.SwissQrCode.Queries;
namespace DocumentService.Application.SwissQrCode.Queries;
/// <summary>
/// Validates ExtractSwissQrCodeQuery before handler execution.
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none).
/// Ensures PdfStream is not null.
/// </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;
}
// Rule: PdfStream must be provided
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

@@ -1,24 +1,19 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.ValidatePdf.Queries;
namespace DocumentService.Application.ValidatePdf.Queries;
/// <summary>
/// Query for PDF validation (supports both byte array and Base64 input)
/// Query for PDF validation (Stream-based)
/// </summary>
public record ValidatePdfQuery : IRequest<PdfValidationResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -33,14 +28,8 @@ public class ValidatePdfQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
/// </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!);
// Convert to stream for IPdfProcessor (using MemoryStream)
using var pdfStream = new MemoryStream(pdfBytes);
// Call DevExpress service (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidateAsync(pdfStream);
// Call DevExpress service directly with stream (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidateAsync(request.PdfStream);
// Map DTO to response DTO using AutoMapper
return Mapper.Map<PdfValidationResult>(metadata);

View File

@@ -1,59 +1,19 @@
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentService.Application.ValidatePdf.Queries;
using FluentValidation;
namespace DocumentOperator.Application.ValidatePdf.Queries;
namespace DocumentService.Application.ValidatePdf.Queries;
/// <summary>
/// Validator for ValidatePdfQuery
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none)
/// Ensures PdfStream is not null
/// </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;
}
// Rule: PdfStream must be provided
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

@@ -1,24 +1,19 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.ValidatePdfA.Queries;
namespace DocumentService.Application.ValidatePdfA.Queries;
/// <summary>
/// Query for PDF/A validation (supports both byte array and Base64 input)
/// Query for PDF/A validation (Stream-based)
/// </summary>
public record ValidatePdfAQuery : IRequest<PdfAValidationResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
public required Stream PdfStream { get; init; }
}
/// <summary>
@@ -33,14 +28,8 @@ public class ValidatePdfAQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper
/// </summary>
public async Task<PdfAValidationResult> Handle(ValidatePdfAQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Convert to stream for IPdfProcessor
using var pdfStream = new MemoryStream(pdfBytes);
// Call DevExpress service (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidatePdfAAsync(pdfStream);
// Call DevExpress service directly with stream (exceptions propagate naturally)
var metadata = await PdfProcessor.ValidatePdfAAsync(request.PdfStream);
// Map DTO to response DTO using AutoMapper
return Mapper.Map<PdfAValidationResult>(metadata);

View File

@@ -1,48 +1,18 @@
using FluentValidation;
namespace DocumentOperator.Application.ValidatePdfA.Validators;
namespace DocumentService.Application.ValidatePdfA.Validators;
/// <summary>
/// Validator for ValidatePdfAQuery
/// Ensures exactly ONE input format is provided (PdfBytes XOR Base64Pdf)
/// Ensures PdfStream is not null
/// </summary>
public class ValidatePdfAQueryValidator : AbstractValidator<Queries.ValidatePdfAQuery>
{
public ValidatePdfAQueryValidator()
{
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");
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
{
RuleFor(x => x.Base64Pdf!)
.Must(BeValidBase64)
.WithMessage("Base64Pdf must be a valid Base64 string");
});
When(x => x.PdfBytes != null, () =>
{
RuleFor(x => x.PdfBytes!)
.Must(bytes => bytes.Length > 0)
.WithMessage("PdfBytes cannot be empty");
});
}
private static bool BeValidBase64(string base64)
{
if (string.IsNullOrWhiteSpace(base64))
return false;
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
// Rule: PdfStream must be provided
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PdfStream is required");
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
namespace DocumentOperator.Domain.Common.Exceptions;
/// <summary>
/// Exception thrown when PDF processing operations fail.
/// Maps to HTTP 500 Internal Server Error or 422 Unprocessable Entity in the API layer.
/// </summary>
[Obsolete("This exception is deprecated. Use more specific exceptions for PDF processing errors.")]
public class PdfProcessingException : DomainException
{
public string Operation { get; }
public PdfProcessingException(string operation, string message)
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR")
{
Operation = operation;
}
public PdfProcessingException(string operation, string message, Exception innerException)
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR", innerException)
{
Operation = operation;
}
public PdfProcessingException(string message)
: base(message, "PDF_PROCESSING_ERROR")
{
Operation = "Unknown";
}
public PdfProcessingException(string message, Exception innerException)
: base(message, "PDF_PROCESSING_ERROR", innerException)
{
Operation = "Unknown";
}
}

View File

@@ -1,23 +0,0 @@
namespace DocumentOperator.Domain.Exceptions;
/// <summary>
/// Exception thrown when a Swiss QR Code cannot be found in a PDF document.
/// </summary>
[Obsolete("This exception is deprecated. Use SwissQrCodeNotFoundException instead.")]
public sealed class SwissQrCodeNotFoundException : Exception
{
public SwissQrCodeNotFoundException()
: base("No Swiss QR Code found on the last page of the PDF document.")
{
}
public SwissQrCodeNotFoundException(string message)
: base(message)
{
}
public SwissQrCodeNotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,18 @@
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Coordinate origin point for PDF annotations.
/// PDF default is BottomLeft, but users may prefer TopLeft for easier UI integration.
/// </summary>
public enum AnnotationOrigin
{
/// <summary>
/// Bottom-left corner (PDF native coordinate system, default)
/// </summary>
BottomLeft,
/// <summary>
/// Top-left corner (common in UI frameworks)
/// </summary>
TopLeft
}

View File

@@ -0,0 +1,32 @@
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Supported PDF annotation types
/// </summary>
public enum AnnotationType
{
/// <summary>
/// Text markup annotation (highlight, underline, strikeout)
/// </summary>
TextMarkup,
/// <summary>
/// Free text annotation (text box with visible text)
/// </summary>
FreeText,
/// <summary>
/// Sticky note annotation (popup comment icon)
/// </summary>
StickyNote,
/// <summary>
/// Circle shape annotation
/// </summary>
Circle,
/// <summary>
/// Square shape annotation
/// </summary>
Square
}

View File

@@ -0,0 +1,32 @@
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Predefined stamp types with standard text and styling.
/// </summary>
public enum PredefinedStampType
{
/// <summary>
/// CONFIDENTIAL stamp (red, bold).
/// </summary>
Confidential,
/// <summary>
/// APPROVED stamp (green, bold).
/// </summary>
Approved,
/// <summary>
/// DRAFT stamp (gray, italic).
/// </summary>
Draft,
/// <summary>
/// VOID stamp (red, strikethrough effect).
/// </summary>
Void,
/// <summary>
/// FOR REVIEW stamp (orange, bold).
/// </summary>
ForReview
}

View File

@@ -0,0 +1,17 @@
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Specifies whether the stamp should appear in the foreground (on top of content) or background (behind content).
/// </summary>
public enum StampPlacement
{
/// <summary>
/// Stamp appears on top of existing page content.
/// </summary>
Foreground,
/// <summary>
/// Stamp appears behind existing page content (watermark effect).
/// </summary>
Background
}

View File

@@ -0,0 +1,22 @@
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Specifies the type of stamp to add to a PDF document.
/// </summary>
public enum StampType
{
/// <summary>
/// Text-based stamp with custom text, font, and color.
/// </summary>
Text,
/// <summary>
/// Image-based stamp (PNG/JPEG overlay).
/// </summary>
Image,
/// <summary>
/// Predefined stamp with standard text (e.g., CONFIDENTIAL, APPROVED).
/// </summary>
Predefined
}

View File

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

View File

@@ -0,0 +1,23 @@
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Text markup annotation style (highlight, underline, strikeout)
/// Maps to DevExpress PdfTextMarkupAnnotationType
/// </summary>
public enum TextMarkupStyle
{
/// <summary>
/// Highlight text with background color
/// </summary>
Highlight,
/// <summary>
/// Underline text
/// </summary>
Underline,
/// <summary>
/// Strikeout text (strikethrough)
/// </summary>
Strikeout
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
using DevExpress.Pdf;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DevExpress.Drawing;
using System.Drawing;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
namespace DocumentOperator.Infrastructure.Services.PdfProcessing;
namespace DocumentService.Infrastructure.Services.PdfProcessing;
/// <summary>
/// PDF processor implementation using DevExpress.Pdf library.
@@ -28,6 +30,12 @@ public class DevExpressPdfProcessor : IPdfProcessor
throw new BadRequestException("PDF stream cannot be empty");
}
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Read stream to byte array for raw data analysis
// (DevExpress needs byte[] for some operations like attachment detection)
byte[] pdfBytes;
@@ -39,14 +47,18 @@ public class DevExpressPdfProcessor : IPdfProcessor
else
{
// Slow path: copy stream to byte array
pdfStream.Position = 0;
using var memoryStream = new MemoryStream();
await pdfStream.CopyToAsync(memoryStream);
pdfBytes = memoryStream.ToArray();
}
// 3. Load PDF with DevExpress Document API
pdfStream.Position = 0;
// Reset position for DevExpress (seekable streams only)
if (pdfStream.CanSeek)
{
pdfStream.Position = 0;
}
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
@@ -87,6 +99,12 @@ public class DevExpressPdfProcessor : IPdfProcessor
throw new BadRequestException("PDF stream cannot be empty");
}
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Read stream to byte array for raw data analysis
byte[] pdfBytes;
if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer))
@@ -95,14 +113,18 @@ public class DevExpressPdfProcessor : IPdfProcessor
}
else
{
pdfStream.Position = 0;
using var memoryStream = new MemoryStream();
await pdfStream.CopyToAsync(memoryStream);
pdfBytes = memoryStream.ToArray();
}
// 3. Load PDF with DevExpress Document API
pdfStream.Position = 0;
// Reset position for DevExpress (seekable streams only)
if (pdfStream.CanSeek)
{
pdfStream.Position = 0;
}
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
@@ -175,8 +197,15 @@ public class DevExpressPdfProcessor : IPdfProcessor
throw new BadRequestException("PDF stream cannot be empty");
}
// 2. Load PDF with DevExpress Document API (exceptions propagate naturally)
pdfStream.Position = 0;
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Load PDF with DevExpress Document API
// DevExpress LoadDocument may throw exceptions for corrupted PDFs - let them propagate naturally
// Middleware will catch and convert to 500 Internal Server Error
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
@@ -207,6 +236,420 @@ public class DevExpressPdfProcessor : IPdfProcessor
);
}
/// <summary>
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
/// Uses DevExpress PdfDocument.FileAttachments to retrieve attachment data.
/// </summary>
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
/// <returns>ZIP archive byte array containing all extracted attachments</returns>
/// <exception cref="BadRequestException">Thrown when stream is empty</exception>
/// <exception cref="NotFoundException">Thrown when PDF contains no attachments</exception>
public async Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream)
{
// 1. Input Validation
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
{
throw new BadRequestException("PDF stream cannot be empty");
}
// Defensive validation: Seekable streams must be at Position = 0
if (pdfStream.CanSeek && pdfStream.Position != 0)
{
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
}
// 2. Load PDF with DevExpress Document API
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
var document = processor.Document;
// 3. Extract attachment data using DevExpress FileAttachments collection
var fileAttachments = document.FileAttachments;
// 4. No attachments case
if (fileAttachments == null || !fileAttachments.Any())
{
throw new NotFoundException("PDF does not contain any attachments");
}
// 5. Create ZIP archive in memory
using var zipStream = new MemoryStream();
using (var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var attachment in fileAttachments)
{
// Get attachment metadata
string fileName = attachment.FileName ?? "unnamed";
byte[] fileData = attachment.Data;
// Create entry in ZIP
var entry = zipArchive.CreateEntry(fileName, System.IO.Compression.CompressionLevel.Optimal);
// Write attachment data to ZIP entry
using var entryStream = entry.Open();
await entryStream.WriteAsync(fileData, 0, fileData.Length);
}
}
// 6. Return ZIP byte array
return zipStream.ToArray();
}
#endregion
#region PDF Merge Operations
public async Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null)
{
// 1. Validate input: minimum 2 PDFs required
if (pdfStreams == null || pdfStreams.Count < 2)
throw new BadRequestException("At least 2 PDF files are required for merging");
// 2. Validate page ranges length (if provided)
if (pageRanges != null && pageRanges.Count != pdfStreams.Count)
throw new BadRequestException($"Page ranges count ({pageRanges.Count}) must match PDF files count ({pdfStreams.Count})");
// 3. Defensive validation: all streams must be at Position = 0
for (int i = 0; i < pdfStreams.Count; i++)
{
var stream = pdfStreams[i];
if (stream == null)
throw new BadRequestException($"PDF stream at index {i} is null");
if (stream.Length == 0)
throw new BadRequestException($"PDF stream at index {i} is empty");
if (stream.CanSeek && stream.Position != 0)
throw new BadRequestException(null, new ArgumentException(
$"PDF stream at index {i} must be positioned at the beginning (Position = 0).",
nameof(pdfStreams)));
}
// 4. Create merged PDF using DevExpress
using var mergedProcessor = new PdfDocumentProcessor();
// Load first PDF as base document
mergedProcessor.LoadDocument(pdfStreams[0]);
// Apply page range to first PDF if specified
if (pageRanges != null && !string.IsNullOrWhiteSpace(pageRanges[0]))
{
var pageIndices = ParsePageRange(pageRanges[0]!, mergedProcessor.Document.Pages.Count);
// Remove pages not in range (process in reverse to maintain indices)
for (int i = mergedProcessor.Document.Pages.Count - 1; i >= 0; i--)
{
if (!pageIndices.Contains(i))
mergedProcessor.Document.Pages.RemoveAt(i);
}
}
// Append remaining PDFs
for (int i = 1; i < pdfStreams.Count; i++)
{
string? pageRange = pageRanges?[i];
if (string.IsNullOrWhiteSpace(pageRange))
{
// Append all pages
mergedProcessor.AppendDocument(pdfStreams[i]);
}
else
{
// Parse page range and append selected pages
// Note: We need to load the document first to validate page range
using var tempProcessor = new PdfDocumentProcessor();
tempProcessor.LoadDocument(pdfStreams[i]);
var pageIndices = ParsePageRange(pageRange, tempProcessor.Document.Pages.Count);
// DevExpress AppendDocument doesn't support arbitrary page selection
// Workaround: Create temp PDF with selected pages, then append
using var tempStream = new MemoryStream();
// Remove unwanted pages from temp document (in reverse order)
for (int j = tempProcessor.Document.Pages.Count - 1; j >= 0; j--)
{
if (!pageIndices.Contains(j))
tempProcessor.Document.Pages.RemoveAt(j);
}
tempProcessor.SaveDocument(tempStream);
tempStream.Position = 0;
mergedProcessor.AppendDocument(tempStream);
}
}
// 5. Save merged PDF to byte array
using var outputStream = new MemoryStream();
mergedProcessor.SaveDocument(outputStream);
return await Task.FromResult(outputStream.ToArray());
}
#endregion
#region PDF Annotation
/// <summary>
/// Adds an annotation to a PDF document at the specified page and rectangle.
/// </summary>
public async Task<byte[]> AddAnnotationAsync(
Stream pdfStream,
Domain.Models.ValueObjects.AnnotationType annotationType,
int pageNumber,
(double X1, double Y1, double X2, double Y2) rectangle,
string? content = null,
string? author = null,
string? color = null,
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft)
{
// 1. Input validation
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException($"PDF stream must be at position 0 (current position: {pdfStream.Position})");
if (pageNumber < 1)
throw new BadRequestException($"Page number must be >= 1 (provided: {pageNumber})");
// Validate content requirement
if (annotationType is Domain.Models.ValueObjects.AnnotationType.FreeText
or Domain.Models.ValueObjects.AnnotationType.StickyNote)
{
if (string.IsNullOrWhiteSpace(content))
throw new BadRequestException($"{annotationType} annotation requires content");
}
// Validate TextMarkup style requirement
if (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup && textMarkupStyle == null)
throw new BadRequestException("TextMarkup annotation requires textMarkupStyle parameter");
byte[] annotatedPdfBytes;
try
{
// 2. Load PDF
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
// 3. Validate page number
int pageCount = processor.Document.Pages.Count;
if (pageNumber > pageCount)
throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})");
// 4. Convert coordinates if origin is TopLeft
var pdfRectangle = rectangle;
if (origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft)
{
var page = processor.Document.Pages[pageNumber - 1];
double pageHeight = page.CropBox.Height;
// Convert Y coordinates: TopLeft → BottomLeft
// TopLeft Y=0 → BottomLeft Y=pageHeight
// TopLeft Y=pageHeight → BottomLeft Y=0
pdfRectangle = (
rectangle.X1,
pageHeight - rectangle.Y2, // Y2 becomes Y1 (top → bottom)
rectangle.X2,
pageHeight - rectangle.Y1 // Y1 becomes Y2 (bottom → top)
);
}
// 5. Get page facade (zero-based index)
var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1];
// 6. Create annotation rectangle from converted coordinates
var pdfRect = new PdfRectangle(pdfRectangle.X1, pdfRectangle.Y1, pdfRectangle.X2, pdfRectangle.Y2);
// 7. Parse color (default to yellow for highlights, red for others)
PdfRGBColor annotationColor = ParseColor(color) ?? (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup
? new PdfRGBColor(1.0, 1.0, 0) // Yellow
: new PdfRGBColor(1.0, 0, 0)); // Red
// 8. Add annotation based on type
switch (annotationType)
{
case Domain.Models.ValueObjects.AnnotationType.TextMarkup:
AddTextMarkupAnnotation(pageFacade, pdfRect, textMarkupStyle!.Value, content, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.FreeText:
AddFreeTextAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.StickyNote:
AddStickyNoteAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.Circle:
AddCircleAnnotation(pageFacade, pdfRect, content, author, annotationColor);
break;
case Domain.Models.ValueObjects.AnnotationType.Square:
AddSquareAnnotation(pageFacade, pdfRect, content, author, annotationColor);
break;
default:
throw new BadRequestException($"Unsupported annotation type: {annotationType}");
}
// 8. Save annotated PDF
using var outputStream = new MemoryStream();
processor.SaveDocument(outputStream);
annotatedPdfBytes = outputStream.ToArray();
}
catch (BadRequestException)
{
throw; // Re-throw our own exceptions
}
catch (Exception ex)
{
throw new BadRequestException($"Failed to add annotation: {ex.Message}");
}
return await Task.FromResult(annotatedPdfBytes);
}
private void AddTextMarkupAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
Domain.Models.ValueObjects.TextMarkupStyle style,
string? content,
string? author,
PdfRGBColor color)
{
// Map our enum to DevExpress enum
var devExpressStyle = style switch
{
Domain.Models.ValueObjects.TextMarkupStyle.Highlight => PdfTextMarkupAnnotationType.Highlight,
Domain.Models.ValueObjects.TextMarkupStyle.Underline => PdfTextMarkupAnnotationType.Underline,
Domain.Models.ValueObjects.TextMarkupStyle.Strikeout => PdfTextMarkupAnnotationType.StrikeOut,
_ => throw new BadRequestException($"Unsupported text markup style: {style}")
};
var annotation = pageFacade.AddTextMarkupAnnotation(rectangle, devExpressStyle);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
if (!string.IsNullOrWhiteSpace(content))
annotation.Contents = content;
}
}
private void AddFreeTextAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string content,
string? author,
PdfRGBColor color)
{
var annotation = pageFacade.AddFreeTextAnnotation(rectangle, content);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
}
}
private void AddStickyNoteAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string content,
string? author,
PdfRGBColor color)
{
// Sticky note uses a point (top-left corner of rectangle)
var point = new PdfPoint(rectangle.Left, rectangle.Top);
var annotation = pageFacade.AddTextAnnotation(point);
if (annotation != null)
{
annotation.Color = color;
annotation.Contents = content;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
}
}
private void AddCircleAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string? content,
string? author,
PdfRGBColor color)
{
var annotation = pageFacade.AddCircleAnnotation(rectangle);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
if (!string.IsNullOrWhiteSpace(content))
annotation.Contents = content;
}
}
private void AddSquareAnnotation(
PdfPageFacade pageFacade,
PdfRectangle rectangle,
string? content,
string? author,
PdfRGBColor color)
{
var annotation = pageFacade.AddSquareAnnotation(rectangle);
if (annotation != null)
{
annotation.Color = color;
if (!string.IsNullOrWhiteSpace(author))
annotation.Author = author;
if (!string.IsNullOrWhiteSpace(content))
annotation.Contents = content;
}
}
/// <summary>
/// Parses hex color string (e.g., "FF0000" for red) to PdfRGBColor
/// </summary>
private PdfRGBColor? ParseColor(string? hexColor)
{
if (string.IsNullOrWhiteSpace(hexColor))
return null;
try
{
// Remove '#' if present
hexColor = hexColor.TrimStart('#');
if (hexColor.Length != 6)
throw new BadRequestException($"Color must be 6-digit hex (e.g., 'FF0000'), got: '{hexColor}'");
int r = Convert.ToInt32(hexColor.Substring(0, 2), 16);
int g = Convert.ToInt32(hexColor.Substring(2, 2), 16);
int b = Convert.ToInt32(hexColor.Substring(4, 2), 16);
return new PdfRGBColor(r / 255.0, g / 255.0, b / 255.0);
}
catch (Exception ex)
{
throw new BadRequestException($"Invalid color format: '{hexColor}'. Expected 6-digit hex (e.g., 'FF0000'). Error: {ex.Message}");
}
}
#endregion
#region Private Helpers
@@ -319,5 +762,444 @@ public class DevExpressPdfProcessor : IPdfProcessor
}
}
/// <summary>
/// Parses page range string into list of zero-based page indices.
/// </summary>
/// <param name="pageRange">Page range string (e.g., "1-3,5" or "1,3,5")</param>
/// <param name="totalPages">Total page count in PDF (for validation)</param>
/// <returns>List of zero-based page indices</returns>
/// <exception cref="BadRequestException">Invalid format or page number out of range</exception>
private static List<int> ParsePageRange(string pageRange, int totalPages)
{
var pageIndices = new HashSet<int>(); // Use HashSet to avoid duplicates
try
{
// Split by comma
string[] parts = pageRange.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (string part in parts)
{
if (part.Contains('-'))
{
// Range format: "1-3"
string[] rangeParts = part.Split('-', StringSplitOptions.TrimEntries);
if (rangeParts.Length != 2)
throw new BadRequestException($"Invalid page range format: '{part}'. Expected format: '1-3'");
if (!int.TryParse(rangeParts[0], out int start) || !int.TryParse(rangeParts[1], out int end))
throw new BadRequestException($"Invalid page numbers in range: '{part}'");
if (start < 1 || end < 1)
throw new BadRequestException($"Page numbers must be >= 1 in range: '{part}'");
if (start > end)
throw new BadRequestException($"Start page must be <= end page in range: '{part}'");
if (start > totalPages || end > totalPages)
throw new BadRequestException($"Page range '{part}' exceeds document page count ({totalPages})");
// Add pages (convert to zero-based indices)
for (int i = start; i <= end; i++)
pageIndices.Add(i - 1);
}
else
{
// Single page: "5"
if (!int.TryParse(part, out int pageNum))
throw new BadRequestException($"Invalid page number: '{part}'");
if (pageNum < 1)
throw new BadRequestException($"Page number must be >= 1: '{part}'");
if (pageNum > totalPages)
throw new BadRequestException($"Page number {pageNum} exceeds document page count ({totalPages})");
pageIndices.Add(pageNum - 1); // Convert to zero-based index
}
}
}
catch (BadRequestException)
{
throw; // Re-throw BadRequestException as-is
}
catch (Exception ex)
{
throw new BadRequestException($"Invalid page range format: '{pageRange}'. Error: {ex.Message}");
}
if (pageIndices.Count == 0)
throw new BadRequestException($"Page range '{pageRange}' resulted in no pages");
return pageIndices.OrderBy(x => x).ToList(); // Return sorted list
}
#endregion
#region AddStampAsync
public async Task<byte[]> AddStampAsync(
Stream pdfStream,
Domain.Models.ValueObjects.StampType stampType,
int[]? pageNumbers,
(double X, double Y) position,
(double Width, double Height)? size = null,
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft,
string? text = null,
string? fontName = null,
double? fontSize = null,
string? color = null,
double? opacity = null,
double? rotation = null,
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
byte[]? imageBytes = null,
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null)
{
// 1. Validate stream
if (pdfStream == null || pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be null or empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream position must be 0");
// 2. Validate stamp type requirements
ValidateStampParameters(stampType, text, imageBytes, predefinedType);
// 3. Validate optional parameters
if (opacity.HasValue && (opacity.Value < 0.0 || opacity.Value > 1.0))
throw new BadRequestException("Opacity must be between 0.0 and 1.0");
if (rotation.HasValue && (rotation.Value < 0 || rotation.Value > 360))
throw new BadRequestException("Rotation must be between 0 and 360 degrees");
if (fontSize.HasValue && fontSize.Value <= 0)
throw new BadRequestException("Font size must be positive");
// 4. Load PDF and calculate target pages
using var processor = new PdfDocumentProcessor();
try
{
processor.LoadDocument(pdfStream);
}
catch (Exception ex)
{
throw new BadRequestException($"Failed to load PDF document: {ex.Message}");
}
int totalPages = processor.Document.Pages.Count;
int[] targetPages = CalculateTargetPages(totalPages, pageNumbers);
// 5. Apply stamp to each target page
foreach (int pageIndex in targetPages)
{
double pageHeight = processor.Document.Pages[pageIndex].CropBox.Height;
// Convert position if origin is TopLeft
var stampPosition = origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft
? (position.X, pageHeight - position.Y)
: position;
// Apply stamp based on type
switch (stampType)
{
case Domain.Models.ValueObjects.StampType.Text:
AddTextStamp(processor, pageIndex, stampPosition, text!, fontName, fontSize, color, opacity, rotation, placement, size);
break;
case Domain.Models.ValueObjects.StampType.Image:
AddImageStamp(processor, pageIndex, stampPosition, imageBytes!, opacity, rotation, placement, size);
break;
case Domain.Models.ValueObjects.StampType.Predefined:
AddPredefinedStamp(processor, pageIndex, stampPosition, predefinedType!.Value, opacity, rotation, placement, size);
break;
}
}
// 6. Save to byte array
using var outputStream = new MemoryStream();
processor.SaveDocument(outputStream);
return await Task.FromResult(outputStream.ToArray());
}
private void ValidateStampParameters(
Domain.Models.ValueObjects.StampType stampType,
string? text,
byte[]? imageBytes,
Domain.Models.ValueObjects.PredefinedStampType? predefinedType)
{
switch (stampType)
{
case Domain.Models.ValueObjects.StampType.Text:
if (string.IsNullOrWhiteSpace(text))
throw new BadRequestException("Text is required for Text stamp type");
break;
case Domain.Models.ValueObjects.StampType.Image:
if (imageBytes == null || imageBytes.Length == 0)
throw new BadRequestException("Image bytes are required for Image stamp type");
break;
case Domain.Models.ValueObjects.StampType.Predefined:
if (!predefinedType.HasValue)
throw new BadRequestException("Predefined type is required for Predefined stamp type");
break;
}
}
private int[] CalculateTargetPages(int totalPages, int[]? pageNumbers)
{
// null = all pages
if (pageNumbers == null)
return Enumerable.Range(0, totalPages).ToArray();
// Validate page numbers (1-based)
foreach (int pageNum in pageNumbers)
{
if (pageNum < 1 || pageNum > totalPages)
throw new BadRequestException($"Page number {pageNum} is out of range (1-{totalPages})");
}
// Convert to zero-based indices
return pageNumbers.Select(p => p - 1).Distinct().OrderBy(p => p).ToArray();
}
private void AddTextStamp(
PdfDocumentProcessor processor,
int pageIndex,
(double X, double Y) position,
string text,
string? fontName,
double? fontSize,
string? color,
double? opacity,
double? rotation,
Domain.Models.ValueObjects.StampPlacement placement,
(double Width, double Height)? size)
{
using var graphics = processor.CreateGraphicsPageSystem();
// Get page object
PdfPage page = processor.Document.Pages[pageIndex];
// Parse color (default: black)
var pdfColor = ParseColor(color) ?? new PdfRGBColor(0, 0, 0);
// Apply opacity (default: 0.5) by creating color with alpha channel
double alpha = opacity ?? 0.5;
Color drawColor = Color.FromArgb((int)(alpha * 255), (int)(pdfColor.R * 255), (int)(pdfColor.G * 255), (int)(pdfColor.B * 255));
// Create font (default: Arial, 12pt)
var font = new DXFont(fontName ?? "Arial", (float)(fontSize ?? 12));
// Calculate bounds
var bounds = size.HasValue
? new RectangleF((float)position.X, (float)position.Y, (float)size.Value.Width, (float)size.Value.Height)
: new RectangleF((float)position.X, (float)position.Y, 200, 50); // Default size
// Apply rotation if specified (around origin, not center point)
if (rotation.HasValue && rotation.Value > 0)
{
// Translate to position, rotate, translate back
graphics.TranslateTransform((float)position.X, (float)position.Y);
graphics.RotateTransform((float)rotation.Value);
graphics.TranslateTransform(-(float)position.X, -(float)position.Y);
}
// Draw text
graphics.DrawString(text, font, new DXSolidBrush(drawColor), bounds);
// Add graphics to page (foreground or background)
if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground)
graphics.AddToPageForeground(page);
else
graphics.AddToPageBackground(page);
}
private void AddImageStamp(
PdfDocumentProcessor processor,
int pageIndex,
(double X, double Y) position,
byte[] imageBytes,
double? opacity,
double? rotation,
Domain.Models.ValueObjects.StampPlacement placement,
(double Width, double Height)? size)
{
using var graphics = processor.CreateGraphicsPageSystem();
// Get page object
PdfPage page = processor.Document.Pages[pageIndex];
// Draw image directly from byte array
try
{
PointF point = new PointF((float)position.X, (float)position.Y);
// Apply rotation if specified
if (rotation.HasValue && rotation.Value > 0)
{
graphics.TranslateTransform((float)position.X, (float)position.Y);
graphics.RotateTransform((float)rotation.Value);
graphics.TranslateTransform(-(float)position.X, -(float)position.Y);
}
// Draw image (DevExpress.Pdf.PdfGraphics.DrawImage accepts byte[] directly)
// Note: Size parameter is ignored for now (DrawImage auto-sizes based on image dimensions)
// If size is needed, we'd need to use DXImage.FromStream and resize
graphics.DrawImage(imageBytes, point);
// Add graphics to page
if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground)
graphics.AddToPageForeground(page);
else
graphics.AddToPageBackground(page);
}
catch (Exception ex)
{
throw new BadRequestException($"Invalid image format or failed to draw image: {ex.Message}");
}
}
private void AddPredefinedStamp(
PdfDocumentProcessor processor,
int pageIndex,
(double X, double Y) position,
Domain.Models.ValueObjects.PredefinedStampType predefinedType,
double? opacity,
double? rotation,
Domain.Models.ValueObjects.StampPlacement placement,
(double Width, double Height)? size)
{
// Get predefined stamp configuration
var (text, color, fontSize, fontStyle) = GetPredefinedStampConfig(predefinedType);
// Delegate to AddTextStamp with predefined parameters
AddTextStamp(processor, pageIndex, position, text, "Arial", fontSize, color, opacity, rotation, placement, size);
}
private (string Text, string Color, double FontSize, string FontStyle) GetPredefinedStampConfig(
Domain.Models.ValueObjects.PredefinedStampType predefinedType)
{
return predefinedType switch
{
Domain.Models.ValueObjects.PredefinedStampType.Confidential => ("CONFIDENTIAL", "FF0000", 24, "Bold"),
Domain.Models.ValueObjects.PredefinedStampType.Approved => ("APPROVED", "00AA00", 24, "Bold"),
Domain.Models.ValueObjects.PredefinedStampType.Draft => ("DRAFT", "808080", 24, "Italic"),
Domain.Models.ValueObjects.PredefinedStampType.Void => ("VOID", "FF0000", 32, "Bold"),
Domain.Models.ValueObjects.PredefinedStampType.ForReview => ("FOR REVIEW", "FFA500", 20, "Bold"),
_ => throw new BadRequestException($"Unsupported predefined stamp type: {predefinedType}")
};
}
#endregion
#region Add Attachments (Phase 2)
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// </summary>
public async Task<byte[]> AddAttachmentsAsync(
Stream pdfStream,
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments)
{
// 1. Validate input
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
ArgumentNullException.ThrowIfNull(attachments, nameof(attachments));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream must be at position 0");
if (attachments.Count == 0)
throw new BadRequestException("At least one attachment is required");
// TODO: Implement AddFileAttachment using DevExpress.Pdf low-level API
// Current limitation: DevExpress.Pdf.PdfDocumentProcessor doesn't directly support adding attachments
// Workaround options:
// 1. Use PdfDocumentProcessor.Document to manipulate PDF structure directly (advanced)
// 2. Use third-party library for this specific operation
// 3. Wait for DevExpress API update
throw new NotImplementedException(
"Add attachments feature is not yet implemented. " +
"DevExpress.Pdf high-level API doesn't directly support adding file attachments. " +
"This requires low-level PDF structure manipulation.");
}
private string InferMimeType(string fileName)
{
string extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".xml" => "application/xml",
".pdf" => "application/pdf",
".json" => "application/json",
".txt" => "text/plain",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
_ => "application/octet-stream"
};
}
#endregion
#region PDF Conversion (Phase 3)
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// </summary>
public async Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel)
{
// 1. Validate input
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream must be at position 0");
if (string.IsNullOrWhiteSpace(pdfALevel))
throw new BadRequestException("PDF/A level is required");
// TODO: Implement PDF to PDF/A conversion using DevExpress
// Current limitation: DevExpress.Pdf.PdfDocumentProcessor doesn't directly support PDF/A conversion
// Requires using specialized PDF/A conversion libraries or low-level PDF manipulation
throw new NotImplementedException(
$"PDF to PDF/A conversion ({pdfALevel}) is not yet implemented. " +
"DevExpress.Pdf high-level API doesn't directly support PDF/A conversion. " +
"This requires specialized PDF/A conversion logic.");
}
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// </summary>
public async Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream)
{
// 1. Validate input
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream must be at position 0");
// 2. Load PDF/A
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
// 3. Save as standard PDF
// DevExpress SaveDocument without special options creates standard PDF
using var outputStream = new MemoryStream();
processor.SaveDocument(outputStream);
return await Task.FromResult(outputStream.ToArray());
}
#endregion
}

View File

@@ -1,13 +1,13 @@
using Codecrete.SwissQRBill.Generator;
using DevExpress.Drawing;
using DevExpress.Pdf;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using SkiaSharp;
using SkiaSharp.QrCode;
using System.Collections.Concurrent;
namespace DocumentOperator.Infrastructure.Services.QrCodeProcessing;
namespace DocumentService.Infrastructure.Services.QrCodeProcessing;
/// <summary>
/// Swiss QR Code processor using DevExpress PDF API for image extraction,
@@ -24,19 +24,27 @@ public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor
/// <inheritdoc />
public async Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
byte[] pdfBytes,
Stream pdfStream,
int[]? pageNumbers = null,
CancellationToken cancellationToken = default)
{
if (pdfBytes.Length == 0)
throw new ArgumentException("PDF document contains no byte data.", nameof(pdfBytes));
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
throw new ArgumentException("PDF stream is empty.", nameof(pdfStream));
// Defensive validation: Seekable streams must be at Position = 0
// Non-seekable streams (e.g., NetworkStream) are not checked
if (pdfStream.CanSeek && pdfStream.Position != 0)
throw new BadRequestException(null, new ArgumentException(
"PDF stream must be positioned at the beginning (Position = 0).",
nameof(pdfStream)));
using var pdfDocument = new PdfDocumentProcessor();
using var pdfStream = new MemoryStream(pdfBytes);
pdfDocument.LoadDocument(pdfStream);
if (pdfDocument.Document.Pages.Count == 0)
throw new ArgumentException("PDF document contains no pages.", nameof(pdfBytes));
throw new ArgumentException("PDF document contains no pages.", nameof(pdfStream));
// Determine which pages to scan
int[] pagesToScan = DeterminePageNumbers(pdfDocument.Document.Pages.Count, pageNumbers);

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.SwissQrCode.Queries;
using DocumentService.API.Controllers; // For ExtractSwissQrCodeBase64Request DTO
using DocumentService.Application.Common.DTOs;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
@@ -7,7 +7,7 @@ using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
namespace DocumentService.Tests.Integration.API;
public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
@@ -27,9 +27,9 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
public async Task POST_ExtractSwissQrCode_ValidRequest_Returns200()
{
// Arrange
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.valid.pdf");
var request = new ExtractSwissQrCodeQuery
var request = new ExtractSwissQrCodeBase64Request
{
Base64Pdf = validPdfBase64
};
@@ -56,7 +56,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
public async Task POST_ExtractSwissQrCode_InvalidBase64_Returns400()
{
// Arrange
var request = new ExtractSwissQrCodeQuery
var request = new ExtractSwissQrCodeBase64Request
{
Base64Pdf = "INVALID_BASE64!!!"
};
@@ -66,6 +66,10 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
@@ -73,7 +77,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
{
// Arrange: References are OPTIONAL - null should not cause validation error (400)
// Using pdfWithSwissQRCode.pdf which actually has a QR code, so we get 200
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.pdfWithSwissQRCode.pdf");
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.pdfWithSwissQRCode.pdf");
var request = new
{

View File

@@ -1,12 +1,12 @@
using DocumentOperator.Application.CheckPdfAttachments.Queries;
using DocumentOperator.Application.Common.DTOs;
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
namespace DocumentService.Tests.Integration.API;
/// <summary>
/// Integration tests for PdfAttachmentController.
@@ -31,7 +31,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
private static async Task<byte[]> LoadTestPdfAsync(string filename)
{
var assembly = typeof(PdfAttachmentControllerTests).Assembly;
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{filename}";
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
@@ -54,7 +54,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -75,7 +75,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -102,7 +102,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
public async Task POST_CheckAttachments_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new CheckPdfAttachmentsQuery { Base64Pdf = "invalid-base64!!!" };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -111,14 +111,15 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_CheckAttachments_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new CheckPdfAttachmentsQuery { Base64Pdf = string.Empty };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -127,7 +128,8 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
@@ -233,7 +235,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
@@ -248,4 +250,134 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
}
#endregion
#region Extract Attachments Tests (Multipart)
[Fact]
public async Task POST_ExtractAttachments_Multipart_ValidPdfWithAttachments_Returns200WithZip()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
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/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP structure
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact]
public async Task POST_ExtractAttachments_Multipart_EmptyPdf_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/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task POST_ExtractAttachments_Multipart_CorruptedPdf_Returns500()
{
// Arrange
byte[] corruptedBytes = "This is not a valid PDF content"u8.ToArray();
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(corruptedBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "corrupted.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
}
#endregion
#region Extract Attachments Tests (Base64)
[Fact]
public async Task POST_ExtractAttachments_Base64_ValidPdfWithAttachments_Returns200WithZip()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP structure
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact]
public async Task POST_ExtractAttachments_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ExtractAttachments_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
}

View File

@@ -0,0 +1,500 @@
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using DocumentService.API.Controllers;
using DocumentService.Domain.Models.ValueObjects;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace DocumentService.Tests.Integration.API;
public class PdfOperationsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public PdfOperationsControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
private static Stream LoadTestPdfAsStream(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{fileName}";
return assembly.GetManifestResourceStream(resourceName)
?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
}
private static string LoadTestPdfAsBase64(string fileName)
{
using var stream = LoadTestPdfAsStream(fileName);
using var ms = new MemoryStream();
stream.CopyTo(ms);
return Convert.ToBase64String(ms.ToArray());
}
#region Merge Endpoint Tests (existing - keeping for reference)
[Fact]
public async Task MergeFromFiles_ValidPdfs_ReturnsMergedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdf1Stream = LoadTestPdfAsStream("valid.pdf");
using var pdf2Stream = LoadTestPdfAsStream("valid.pdf");
var pdf1Content = new StreamContent(pdf1Stream);
var pdf2Content = new StreamContent(pdf2Stream);
pdf1Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
pdf2Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdf1Content, "files", "file1.pdf");
content.Add(pdf2Content, "files", "file2.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
mergedPdf.Length.Should().BeGreaterThan(100); // Sanity check
}
[Fact]
public async Task MergeFromBase64_ValidPdfs_ReturnsMergedPdf()
{
// Arrange
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
PageRanges = null
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task MergeFromBase64_WithPageRanges_ReturnsMergedPdf()
{
// Arrange
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
PageRanges = new List<string?> { "1", "1" } // Only first page from each
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task MergeFromFiles_OnePdf_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "files", "file1.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromBase64_InvalidBase64_Returns400()
{
// Arrange
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { "INVALID_BASE64!!!", "ANOTHER_INVALID" }
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromBase64_InvalidPageRange_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf, base64Pdf },
PageRanges = new List<string?> { "999-1000", null } // Exceeds page count
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromFiles_CorruptedPdf_Returns500()
{
// Arrange
using var content = new MultipartFormDataContent();
byte[] corruptedData = "NOT A PDF FILE"u8.ToArray();
var corruptedContent = new ByteArrayContent(corruptedData);
corruptedContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var validPdfStream = LoadTestPdfAsStream("valid.pdf");
var validContent = new StreamContent(validPdfStream);
validContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(corruptedContent, "files", "corrupted.pdf");
content.Add(validContent, "files", "valid.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
}
#endregion
#region Annotate Endpoint Tests
[Fact]
public async Task AnnotateFromFile_TextMarkupHighlight_ReturnsAnnotatedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "file", "test.pdf");
content.Add(new StringContent(AnnotationType.TextMarkup.ToString()), "annotationType");
content.Add(new StringContent("1"), "pageNumber");
content.Add(new StringContent("100"), "x1");
content.Add(new StringContent("100"), "y1");
content.Add(new StringContent("200"), "x2");
content.Add(new StringContent("120"), "y2");
content.Add(new StringContent("Important text"), "content");
content.Add(new StringContent("Test Author"), "author");
content.Add(new StringContent("FFFF00"), "color");
content.Add(new StringContent(TextMarkupStyle.Highlight.ToString()), "textMarkupStyle");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
annotatedPdf.Length.Should().BeGreaterThan(100);
}
[Fact]
public async Task AnnotateFromBase64_FreeText_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 50,
Y1 = 50,
X2 = 150,
Y2 = 100,
Content = "Free text annotation",
Author = "John Doe",
Color = "FF0000"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_StickyNote_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.StickyNote,
PageNumber = 1,
X1 = 300,
Y1 = 300,
X2 = 320,
Y2 = 320,
Content = "Please review this section",
Author = "Reviewer"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromFile_Circle_ReturnsAnnotatedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "file", "test.pdf");
content.Add(new StringContent(AnnotationType.Circle.ToString()), "annotationType");
content.Add(new StringContent("1"), "pageNumber");
content.Add(new StringContent("100"), "x1");
content.Add(new StringContent("200"), "y1");
content.Add(new StringContent("200"), "x2");
content.Add(new StringContent("300"), "y2");
content.Add(new StringContent("Circle annotation"), "content");
content.Add(new StringContent("00FF00"), "color");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_Square_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.Square,
PageNumber = 1,
X1 = 250,
Y1 = 250,
X2 = 350,
Y2 = 350,
Color = "0000FF"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_InvalidBase64_Returns400()
{
// Arrange
var request = new AddAnnotationBase64Command
{
Base64Pdf = "INVALID_BASE64!!!",
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_InvalidPageNumber_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 999, // Exceeds page count
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100,
Content = "Test"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_FreeTextWithoutContent_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
// Missing required Content
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_TextMarkupWithoutStyle_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
// Missing required TextMarkupStyle
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_InvalidColorFormat_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100,
Color = "INVALID" // Invalid hex format
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
}

View File

@@ -1,13 +1,12 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Application.ValidatePdfA.Queries;
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
namespace DocumentService.Tests.Integration.API;
public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
@@ -28,7 +27,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
// Verwende ein echtes Test-PDF (embedded resource aus Unit Tests)
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
@@ -44,7 +43,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfQuery { Base64Pdf = base64Pdf };
var request = new ValidatePdfBase64Request { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
@@ -63,7 +62,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
public async Task POST_ValidatePdf_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfQuery { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
var request = new ValidatePdfBase64Request { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
@@ -72,14 +71,15 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ValidatePdf_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfQuery { Base64Pdf = string.Empty }; // Leerer String
var request = new ValidatePdfBase64Request { Base64Pdf = string.Empty }; // Leerer String
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
@@ -88,7 +88,8 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
@@ -100,7 +101,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
@@ -175,7 +176,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
@@ -191,7 +192,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfAQuery { Base64Pdf = base64Pdf };
var request = new ValidatePdfABase64Request { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
@@ -210,7 +211,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
public async Task POST_ValidatePdfA_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfAQuery { Base64Pdf = "invalid-base64!!!" };
var request = new ValidatePdfABase64Request { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
@@ -219,14 +220,15 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ValidatePdfA_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfAQuery { Base64Pdf = string.Empty };
var request = new ValidatePdfABase64Request { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
@@ -235,7 +237,8 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
@@ -247,7 +250,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))

View File

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

View File

@@ -1,12 +1,12 @@
using AutoMapper;
using DocumentOperator.Application.CheckPdfAttachments.Queries;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.CheckPdfAttachments.Queries;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using FluentAssertions;
using Moq;
namespace DocumentOperator.Tests.Unit.Application.CheckPdfAttachments;
namespace DocumentService.Tests.Unit.Application.CheckPdfAttachments;
/// <summary>
/// Unit tests for CheckPdfAttachmentsQueryHandler.
@@ -30,7 +30,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
{
// Arrange
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainResult = new AttachmentInfo(
hasAttachments: true,
@@ -72,8 +72,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
{
// Arrange
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
string base64Pdf = Convert.ToBase64String(pdfBytes);
var query = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainResult = new AttachmentInfo(false, 0, []);
var expectedDto = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0, Attachments = [] };
@@ -97,7 +96,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
{
// Arrange
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainResult = new AttachmentInfo(false, 0, []);
var expectedDto = new AttachmentCheckResult

View File

@@ -1,13 +1,13 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ValidatePdf.Queries;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentOperator.Tests.Unit.Application.Features.ValidatePdf;
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdf;
public class ValidatePdfHandlerTests
{
@@ -27,7 +27,7 @@ public class ValidatePdfHandlerTests
{
// Arrange
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainMetadata = new PdfMetadata(
pageCount: 5,
@@ -74,7 +74,7 @@ public class ValidatePdfHandlerTests
{
// Arrange
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
_mockPdfProcessor
.Setup(x => x.ValidateAsync(It.IsAny<Stream>()))

View File

@@ -1,14 +1,14 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Application.ValidatePdfA.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ValidatePdfA.Queries;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentOperator.Tests.Unit.Application.Features.ValidatePdfA;
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdfA;
public class ValidatePdfAQueryHandlerTests
{
@@ -28,7 +28,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainMetadata = new PdfAMetadata(
isValid: true,
@@ -86,7 +86,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfAQuery { Base64Pdf = Convert.ToBase64String(pdfBytes) };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
var errors = new List<string> { "Missing XMP metadata", "Invalid color space" };
var warnings = new List<string> { "Embedded font not subset" };
@@ -144,7 +144,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
var domainMetadata = new PdfAMetadata(
isValid: true,
@@ -196,7 +196,7 @@ public class ValidatePdfAQueryHandlerTests
{
// Arrange
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
_mockPdfProcessor
.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))

View File

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

View File

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

View File

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

View File

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

View File

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

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