Compare commits

...

18 Commits

Author SHA1 Message Date
0db28a025f chore: add empty root-level Program.cs placeholder 2026-09-21 10:10:47 +02:00
6a321c1022 build(sln): register Tests.Live and Tests.NuGet projects; expand platform configurations
- Add DocumentService.Tests.Live (xUnit live tests, GUID CC5A7D7B) to solution
  under the existing Tests solution folder
- Add DocumentService.Tests.NuGet (smoke-test console app, GUID EE3E7C50) to
  solution under the Tests solution folder
- Expand GlobalSection(SolutionConfigurationPlatforms) to include x64 and x86
  variants for Debug and Release alongside the existing Any CPU entries
- Add corresponding ProjectConfigurationPlatforms entries for all projects
  (existing + new) so VS/MSBuild can resolve the correct output paths when
  targeting x64/x86 explicitly
2026-09-21 10:10:39 +02:00
dcb735f8c8 test(live): add live API integration test project (DocumentService.Tests.Live)
End-to-end tests that run against a real, deployed API instance to verify that
all controllers respond correctly over HTTP - bypassing WebApplicationFactory.

Project setup:
- Targets net8.0; xUnit 2.9.3 + FluentAssertions 7.0.0
- References DocumentService.Client, Application and Domain as project refs
- Embeds 4 test PDFs as EmbeddedResources:
    valid.pdf, pdfWithSwissQRCode.pdf, pdfWithMoreThanOneAttachment.pdf,
    encryptedPdf.pdf

LiveApiTestBase:
- Base URL defaults to http://172.24.12.39:9393, overridable via
  LIVE_API_BASE_URL env var
- Constructs typed clients (PdfValidationClient, PdfAttachmentClient,
  PdfOperationsClient, SwissQrCodeClient) backed by a single HttpClient
- LoadPdfBytes / LoadPdfStream helpers for EmbeddedResource access

Test coverage per file:
- PdfValidationLiveTests   : Stream + byte[] overloads for validate and
                             validate-pdfa; empty/corrupted inputs assert 4xx/5xx
- PdfAttachmentLiveTests   : check + extract endpoints; stream and byte[] variants
- PdfOperationsLiveTests   : merge and stamp endpoints
- SwissQrCodeLiveTests     : extract Swiss QR bill; invalid content asserts error

Tests are intentionally skipped in CI (no live server); run manually with
the LIVE_API_BASE_URL variable pointing to a running instance.
2026-09-21 10:10:28 +02:00
6527cdcbb0 test(nuget): add NuGet smoke-test console app (DocumentService.Tests.NuGet)
Standalone console application that references the published DocumentService.Client
NuGet package (v1.2.2) and verifies the full happy-path against a live API instance.

- Targets net8.0; references DocumentService.Client 1.2.2 via NuGet (not project ref)
- Embeds valid.pdf as an EmbeddedResource so no file-system path is required
- Calls Client.Validation.ValidateAsync(Stream) and prints PageCount, PdfVersion,
  IsEncrypted, AttachmentCount, FileSizeBytes to stdout
- Handles HttpRequestException and InvalidOperationException with descriptive output
- Intended to run once after a new package is pushed to confirm the .nupkg bundles
  Application and Domain DLLs correctly (validates the embedding targets added in
  the previous commit)
2026-09-21 10:10:09 +02:00
24db1b0d0a build(client): bump version to 1.2.2 and embed Application/Domain DLLs into NuGet package
- Bump Version, AssemblyVersion, FileVersion from 1.2.0 to 1.2.2
- Mark Application and Domain ProjectReferences as PrivateAssets=all so they
  are not propagated as transitive dependencies to consumers
- Add IncludeReferencedProjectDlls MSBuild target to pack Application.dll and
  Domain.dll into the correct lib/<tfm>/ path inside the .nupkg
- Add CopyProjectReferencesToPackage target (TargetsForTfmSpecificBuildOutput)
  as the per-TFM companion needed for multi-targeting builds to pick up both
  referenced project DLLs correctly
- Fix stray UTF-8 BOM on the opening Project element
2026-09-21 10:09:56 +02:00
f7cba05490 Update README.md with details on PDF/A validation
Added a section explaining the limitations of the `ValidatePdfAAsync` method, highlighting that it performs heuristic PDF/A detection rather than full ISO-19005 compliance checks. Listed specific requirements not validated and clarified the meaning of `IsValid = true`. Updated the API endpoint table to reflect the heuristic nature of the `ValidatePdfAAsync()` method.
2026-08-31 16:06:32 +02:00
a618e3559a Bump version to 1.2.0 and enhance QR code documentation
Updated `DocumentService.Client.csproj` to version 1.2.0, reflecting new features and improvements. Enhanced `README.md` with detailed examples for the new `InspectSwissQrCodeAsync` method, showcasing two approaches (TryExtract and direct null-check) for accessing QR code data. Added PowerShell-specific notes and improved code formatting for better readability. Clarified that `TryExtract` is an optional helper method.
2026-08-31 15:39:36 +02:00
68eeeb1eda Add TryExtract method to SwissQrCodeResult record
Replaced the fully qualified reference to
`DocumentService.Application.Common.DTOs.PdfValidationResult.IsValid`
with the simpler `PdfValidationResult.IsValid`.

Added a `TryExtract` method to the `SwissQrCodeResult` record to
improve code readability and usability. The method follows the
standard `TryXxx` pattern, allowing for an `if/else` branch
instead of a direct null-check on the `QrCode` property. It
returns `true` if the PDF was valid and a Swiss QR Code was
successfully extracted, and `false` otherwise.
2026-08-31 15:39:11 +02:00
0e22ffb2c5 Move WorkflowsClientExtensions to IWorkflowsClient.cs
Relocated the `WorkflowsClientExtensions` class from `WorkflowsClient.cs` to `IWorkflowsClient.cs` to improve discoverability by defining it alongside the `IWorkflowsClient` interface.

The `InspectSwissQrCodeAsync` extension method was moved as part of this change. Its functionality remains unchanged, as it still opens a `FileStream` for the provided file path and delegates to the `IWorkflowsClient.InspectSwissQrCodeAsync(Stream, bool, CancellationToken)` method.
2026-08-31 15:35:14 +02:00
62ab576e69 Update Swiss QR Code API to use InspectSwissQrCodeAsync
Replaced `ExtractSwissQrCodeAsync` with `InspectSwissQrCodeAsync`,
which combines validation and extraction into a single operation.
Updated README examples and code snippets (C#, VB.NET, PowerShell)
to reflect the new method. Removed `throwIfInvalid` parameter and
deprecated the old method with `[Obsolete]` warnings.

Documented the new method's behavior, including always returning
a `SwissQrCodeResult` object and handling invalid documents
gracefully without exceptions. Updated API reference table and
removed outdated error-handling examples. Added a note about the
planned server-side implementation of the new endpoint.
2026-08-31 14:45:37 +02:00
092d283a2b Refactor WorkflowsClient for unified result handling
Refactored `ExtractSwissQrCodeAsync` to `InspectSwissQrCodeAsync`, introducing `SwissQrCodeResult` to encapsulate both validation and extraction results. Removed `throwIfInvalid` parameter, ensuring validation details are always returned without exceptions. Added extension methods for file path handling to improve usability.

Updated tests to reflect the new behavior, ensuring extraction is never attempted on invalid documents and validation results are always populated. Simplified API surface by removing redundant overloads and improving stream handling. Enhanced documentation and performed general code cleanup for better maintainability.
2026-08-31 14:44:57 +02:00
71dd32f11c Update method names and improve API consistency
Replaced `ExtractAsync` with `ExtractSwissQrCodeAsync` in C#, VB.NET, and PowerShell examples for Swiss QR Code extraction. Updated the `throwIfInvalid` parameter to allow handling invalid PDFs without exceptions.

Revised the API endpoint table in README.md to reflect updated method names (`ExtractSwissQrCodeAsync` replaced with `ExtractAsync` for `Client.Attachment`, `Client.SwissQrCode`, and `Client.Zugferd`). Retained `ExtractSwissQrCodeAsync` for `Client.Workflows`.

These changes improve clarity, consistency, and functionality in the API and documentation.
2026-08-31 14:02:59 +02:00
f9d4e53ddc Update NuGet package version to 1.1.0 in README.md
The NuGet package version for `documentservice.client` has been
updated from `1.0.0` to `1.1.0` in the documentation. Changes
include:
- Updating the **NuGet-Paket** URL under the "Installation" section.
- Updating the PowerShell script example for downloading and
  extracting the package.

This ensures users reference the latest version of the package.
2026-08-31 03:43:03 +02:00
d92e7f0b07 Update version to 1.1.0 for DocumentService.Client
Updated the project version from 1.0.0 to 1.1.0 to reflect new changes or improvements. Updated `AssemblyVersion` and `FileVersion` to 1.1.0.0 to maintain consistency with the new project version.
2026-08-31 03:42:11 +02:00
189622c476 Refactor: Standardize method names across clients
Renamed methods across multiple client classes for consistency,
shortening and aligning naming conventions (e.g., `CheckAttachmentsAsync` → `CheckAsync`, `ValidatePdfAsync` → `ValidateAsync`).

Updated corresponding interfaces, unit tests, and documentation
to reflect the new method names. Standardized method signatures
to support both `Stream` and `byte[]` overloads consistently.

Improved error handling in tests and updated examples in the
README for batch processing and validation scenarios. Enhanced
API endpoint overview for clarity.
2026-08-31 03:40:43 +02:00
f51fde6a23 Add Workflows feature with Swiss QR Code validation
Introduced the `Client.Workflows` feature to enable combined
operations with pre-validation. Added the method
`ExtractSwissQrCodeValidatedAsync()` for extracting Swiss QR
Code data with validation in one step.

Updated the README to document the new feature, including
usage examples in C#, VB.NET, and PowerShell, details on the
`throwIfInvalid` parameter, and method signature.

Added the method to the API Endpoints section with a note
about its current `NotImplementedException` status. Clarified
the use of `[Obsolete]` for unimplemented endpoints. Enhanced
the PowerShell note to recommend threading for long-running
operations.
2026-08-31 03:30:28 +02:00
f4d87b42f3 Add unit tests for WorkflowsClient validation/extraction
Added `WorkflowsClientTests` to validate the behavior of the
`WorkflowsClient` class, covering PDF validation and Swiss QR
code extraction. Introduced test doubles for `IPdfValidationClient`
and `ISwissQrCodeClient` using `Moq`.

Implemented tests for all overloads of `ExtractSwissQrCodeValidatedAsync`:
- Byte array, stream, and file path inputs.
- Valid, invalid, and empty PDFs.
- Raw mode and cancellation token handling.
- Exception propagation and orchestration contracts.

Ensured comprehensive test coverage for all branches and edge cases.
2026-08-31 03:29:33 +02:00
0c27e4fd91 Add WorkflowsClient for PDF validation and QR extraction
Introduced a new `WorkflowsClient` to orchestrate multi-step
operations, combining `IPdfValidationClient` and
`ISwissQrCodeClient`. Added methods to validate PDFs and extract
Swiss QR Codes in a single call, with overloads for byte arrays,
streams, and file paths. Registered `WorkflowsClient` as a scoped
service in `ServiceCollectionExtensions`.

Updated `PdfValidationResult` with an `IsValid` property to
simplify PDF validity checks. Added `IWorkflowsClient` interface
to define the contract for the new client, with detailed XML
documentation for its methods.
2026-08-31 03:21:41 +02:00
38 changed files with 2092 additions and 170 deletions

View File

@@ -17,4 +17,7 @@ public record PdfValidationResult(
string PdfVersion, string PdfVersion,
bool HasAttachments, bool HasAttachments,
int AttachmentCount, int AttachmentCount,
bool IsEncrypted); bool IsEncrypted)
{
public bool IsValid => !IsEncrypted && PageCount > 0 && FileSizeBytes > 0;
}

View File

@@ -89,5 +89,7 @@ public static class Client
public static ISwissQrCodeClient SwissQrCode => GetRequiredServiceOfScope<ISwissQrCodeClient>(); public static ISwissQrCodeClient SwissQrCode => GetRequiredServiceOfScope<ISwissQrCodeClient>();
public static IZugferdClient Zugferd => GetRequiredServiceOfScope<IZugferdClient>(); public static IZugferdClient Zugferd => GetRequiredServiceOfScope<IZugferdClient>();
public static IWorkflowsClient Workflows => GetRequiredServiceOfScope<IWorkflowsClient>();
#endregion #endregion
} }

View File

@@ -14,7 +14,7 @@ public class PdfAttachmentClient(HttpClient httpClient, ILogger<PdfAttachmentCli
: BaseDocumentClient(httpClient, logger), IPdfAttachmentClient : BaseDocumentClient(httpClient, logger), IPdfAttachmentClient
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<AttachmentCheckResult> CheckAttachmentsAsync(Stream pdfStream, CancellationToken cancellationToken = default) public async Task<AttachmentCheckResult> CheckAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{ {
Logger.LogDebug("Checking PDF attachments from stream (multipart)"); Logger.LogDebug("Checking PDF attachments from stream (multipart)");
@@ -28,7 +28,7 @@ public class PdfAttachmentClient(HttpClient httpClient, ILogger<PdfAttachmentCli
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<AttachmentCheckResult> CheckAttachmentsAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) public async Task<AttachmentCheckResult> CheckAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
Logger.LogDebug("Checking PDF attachments from byte array (Base64 JSON)"); Logger.LogDebug("Checking PDF attachments from byte array (Base64 JSON)");
@@ -43,7 +43,7 @@ public class PdfAttachmentClient(HttpClient httpClient, ILogger<PdfAttachmentCli
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<Dictionary<string, Stream>> ExtractAttachmentsAsync(Stream pdfStream, CancellationToken cancellationToken = default) public async Task<Dictionary<string, Stream>> ExtractAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{ {
Logger.LogDebug("Extracting PDF attachments from stream (multipart)"); Logger.LogDebug("Extracting PDF attachments from stream (multipart)");
@@ -57,7 +57,7 @@ public class PdfAttachmentClient(HttpClient httpClient, ILogger<PdfAttachmentCli
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<Dictionary<string, Stream>> ExtractAttachmentsAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) public async Task<Dictionary<string, Stream>> ExtractAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
Logger.LogDebug("Extracting PDF attachments from byte array (Base64 JSON)"); Logger.LogDebug("Extracting PDF attachments from byte array (Base64 JSON)");
@@ -73,7 +73,7 @@ public class PdfAttachmentClient(HttpClient httpClient, ILogger<PdfAttachmentCli
/// <inheritdoc /> /// <inheritdoc />
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
public Task<Stream> AddAttachmentsAsync(Stream pdfStream, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default) public Task<Stream> AddAsync(Stream pdfStream, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default)
{ {
Logger.LogWarning("AddAttachments endpoint is not implemented yet in API"); Logger.LogWarning("AddAttachments endpoint is not implemented yet in API");
throw new NotImplementedException("API endpoint /api/pdf/attachments/add is not implemented yet"); throw new NotImplementedException("API endpoint /api/pdf/attachments/add is not implemented yet");
@@ -81,7 +81,7 @@ public class PdfAttachmentClient(HttpClient httpClient, ILogger<PdfAttachmentCli
/// <inheritdoc /> /// <inheritdoc />
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
public Task<Stream> AddAttachmentsAsync(byte[] pdfBytes, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default) public Task<Stream> AddAsync(byte[] pdfBytes, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default)
{ {
Logger.LogWarning("AddAttachments endpoint is not implemented yet in API"); Logger.LogWarning("AddAttachments endpoint is not implemented yet in API");
throw new NotImplementedException("API endpoint /api/pdf/attachments/add is not implemented yet"); throw new NotImplementedException("API endpoint /api/pdf/attachments/add is not implemented yet");

View File

@@ -16,7 +16,7 @@ public class PdfConversionClient(HttpClient httpClient, ILogger<PdfConversionCli
{ {
/// <inheritdoc /> /// <inheritdoc />
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
public Task<Stream> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default) public Task<Stream> ToPdfAAsync(Stream pdfStream, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default)
{ {
Logger.LogWarning("ConvertToPdfA endpoint is not implemented yet in API"); Logger.LogWarning("ConvertToPdfA endpoint is not implemented yet in API");
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/to-pdfa is not implemented yet"); throw new NotImplementedException("API endpoint POST /api/pdf/conversion/to-pdfa is not implemented yet");
@@ -24,7 +24,7 @@ public class PdfConversionClient(HttpClient httpClient, ILogger<PdfConversionCli
/// <inheritdoc /> /// <inheritdoc />
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
public Task<Stream> ConvertToPdfAAsync(byte[] pdfBytes, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default) public Task<Stream> ToPdfAAsync(byte[] pdfBytes, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default)
{ {
Logger.LogWarning("ConvertToPdfA endpoint is not implemented yet in API"); Logger.LogWarning("ConvertToPdfA endpoint is not implemented yet in API");
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/to-pdfa is not implemented yet"); throw new NotImplementedException("API endpoint POST /api/pdf/conversion/to-pdfa is not implemented yet");
@@ -32,7 +32,7 @@ public class PdfConversionClient(HttpClient httpClient, ILogger<PdfConversionCli
/// <inheritdoc /> /// <inheritdoc />
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
public Task<Stream> ConvertFromPdfAAsync(Stream pdfStream, CancellationToken cancellationToken = default) public Task<Stream> FromPdfAAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{ {
Logger.LogWarning("ConvertFromPdfA endpoint is not implemented yet in API"); Logger.LogWarning("ConvertFromPdfA endpoint is not implemented yet in API");
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/from-pdfa is not implemented yet"); throw new NotImplementedException("API endpoint POST /api/pdf/conversion/from-pdfa is not implemented yet");
@@ -40,7 +40,7 @@ public class PdfConversionClient(HttpClient httpClient, ILogger<PdfConversionCli
/// <inheritdoc /> /// <inheritdoc />
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
public Task<Stream> ConvertFromPdfAAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) public Task<Stream> FromPdfAAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
Logger.LogWarning("ConvertFromPdfA endpoint is not implemented yet in API"); Logger.LogWarning("ConvertFromPdfA endpoint is not implemented yet in API");
throw new NotImplementedException("API endpoint POST /api/pdf/conversion/from-pdfa is not implemented yet"); throw new NotImplementedException("API endpoint POST /api/pdf/conversion/from-pdfa is not implemented yet");

View File

@@ -12,7 +12,7 @@ namespace DocumentService.Client.Clients;
public class PdfValidationClient(HttpClient httpClient, ILogger<PdfValidationClient> logger) : BaseDocumentClient(httpClient, logger), IPdfValidationClient public class PdfValidationClient(HttpClient httpClient, ILogger<PdfValidationClient> logger) : BaseDocumentClient(httpClient, logger), IPdfValidationClient
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<PdfValidationResult> ValidatePdfAsync(Stream pdfStream, CancellationToken cancellationToken = default) public async Task<PdfValidationResult> ValidateAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{ {
var result = await SendMultipartAsync<PdfValidationResult>( var result = await SendMultipartAsync<PdfValidationResult>(
"/api/pdf/validation/validate", "/api/pdf/validation/validate",
@@ -24,7 +24,7 @@ public class PdfValidationClient(HttpClient httpClient, ILogger<PdfValidationCli
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<PdfValidationResult> ValidatePdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) public async Task<PdfValidationResult> ValidateAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
var request = new ValidatePdfBase64Request var request = new ValidatePdfBase64Request
{ {

View File

@@ -12,7 +12,7 @@ namespace DocumentService.Client.Clients;
public class SwissQrCodeClient(HttpClient httpClient, ILogger<SwissQrCodeClient> logger) : BaseDocumentClient(httpClient, logger), ISwissQrCodeClient public class SwissQrCodeClient(HttpClient httpClient, ILogger<SwissQrCodeClient> logger) : BaseDocumentClient(httpClient, logger), ISwissQrCodeClient
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<SwissQrCodeExtractionResult> ExtractSwissQrCodeAsync(Stream pdfStream, bool raw = false, CancellationToken cancellationToken = default) public async Task<SwissQrCodeExtractionResult> ExtractAsync(Stream pdfStream, bool raw = false, CancellationToken cancellationToken = default)
{ {
var endpoint = $"/api/pdf/qr-code/extract-swiss?raw={raw}"; var endpoint = $"/api/pdf/qr-code/extract-swiss?raw={raw}";
@@ -26,7 +26,7 @@ public class SwissQrCodeClient(HttpClient httpClient, ILogger<SwissQrCodeClient>
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<SwissQrCodeExtractionResult> ExtractSwissQrCodeAsync(byte[] pdfBytes, bool raw = false, CancellationToken cancellationToken = default) public async Task<SwissQrCodeExtractionResult> ExtractAsync(byte[] pdfBytes, bool raw = false, CancellationToken cancellationToken = default)
{ {
var endpoint = $"/api/pdf/qr-code/extract-swiss?raw={raw}"; var endpoint = $"/api/pdf/qr-code/extract-swiss?raw={raw}";

View File

@@ -0,0 +1,44 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Results;
namespace DocumentService.Client.Clients;
/// <summary>
/// Orchestrated multi-step workflows that compose multiple DocumentService clients
/// into single, higher-level operations.
/// </summary>
public class WorkflowsClient(IPdfValidationClient validation, ISwissQrCodeClient swissQrCode) : IWorkflowsClient
{
/// <inheritdoc />
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
byte[] pdfBytes,
bool raw = false,
CancellationToken cancellationToken = default)
{
var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken);
var qrCode = validationResult.IsValid
? await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken)
: null;
return new SwissQrCodeResult(qrCode, validationResult);
}
/// <inheritdoc />
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
Stream pdfStream,
bool raw = false,
CancellationToken cancellationToken = default)
{
var validationResult = await validation.ValidateAsync(pdfStream, cancellationToken);
if (!validationResult.IsValid)
return new SwissQrCodeResult(null, validationResult);
// Stream was consumed by validation – reset if possible, otherwise re-open is caller's responsibility.
if (pdfStream.CanSeek)
pdfStream.Position = 0;
var qrCode = await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken);
return new SwissQrCodeResult(qrCode, validationResult);
}
}

View File

@@ -13,7 +13,7 @@ namespace DocumentService.Client.Clients;
public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger) : BaseDocumentClient(httpClient, logger), IZugferdClient public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger) : BaseDocumentClient(httpClient, logger), IZugferdClient
{ {
/// <inheritdoc /> /// <inheritdoc />
public async Task<ZugferdCheckResult> HasZugferdAsync(Stream pdfStream, CancellationToken cancellationToken = default) public async Task<ZugferdCheckResult> CheckAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{ {
var result = await SendMultipartAsync<ZugferdCheckResult>( var result = await SendMultipartAsync<ZugferdCheckResult>(
"/api/pdf/zugferd/has-zugferd", "/api/pdf/zugferd/has-zugferd",
@@ -25,7 +25,7 @@ public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger)
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<ZugferdCheckResult> HasZugferdAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) public async Task<ZugferdCheckResult> CheckAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
var request = new HasZugferdRequest { Base64Pdf = ToBase64(pdfBytes) }; var request = new HasZugferdRequest { Base64Pdf = ToBase64(pdfBytes) };
@@ -38,7 +38,7 @@ public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger)
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<Stream> ExtractZugferdAsync(Stream pdfStream, CancellationToken cancellationToken = default) public async Task<Stream> ExtractAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{ {
// asFile=true returns the XML file directly (application/xml stream) // asFile=true returns the XML file directly (application/xml stream)
return await SendMultipartForStreamAsync( return await SendMultipartForStreamAsync(
@@ -49,7 +49,7 @@ public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger)
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<Stream> ExtractZugferdAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) public async Task<Stream> ExtractAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
var request = new ExtractZugferdRequest { Base64Pdf = ToBase64(pdfBytes) }; var request = new ExtractZugferdRequest { Base64Pdf = ToBase64(pdfBytes) };
@@ -61,7 +61,7 @@ public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger)
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<ZugferdExtractionResult> ExtractZugferdAsResultAsync(Stream pdfStream, CancellationToken cancellationToken = default) public async Task<ZugferdExtractionResult> ExtractAsResultAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{ {
// asFile=false returns JSON with metadata + XML content // asFile=false returns JSON with metadata + XML content
var result = await SendMultipartAsync<ZugferdExtractionResult>( var result = await SendMultipartAsync<ZugferdExtractionResult>(
@@ -74,7 +74,7 @@ public class ZugferdClient(HttpClient httpClient, ILogger<ZugferdClient> logger)
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<ZugferdExtractionResult> ExtractZugferdAsResultAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) public async Task<ZugferdExtractionResult> ExtractAsResultAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{ {
var request = new ExtractZugferdRequest { Base64Pdf = ToBase64(pdfBytes) }; var request = new ExtractZugferdRequest { Base64Pdf = ToBase64(pdfBytes) };

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks> <TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
@@ -11,9 +11,9 @@
<PackageIcon>icon.png</PackageIcon> <PackageIcon>icon.png</PackageIcon>
<RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl> <RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl>
<PackageTags>digital data document service api client</PackageTags> <PackageTags>digital data document service api client</PackageTags>
<Version>1.0.0</Version> <Version>1.2.2</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.2.2.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.2.2.0</FileVersion>
<Description></Description> <Description></Description>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@@ -44,7 +44,34 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DocumentService.Application\DocumentService.Application.csproj" /> <ProjectReference Include="..\DocumentService.Application\DocumentService.Application.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<ProjectReference Include="..\DocumentService.Domain\DocumentService.Domain.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
</ItemGroup> </ItemGroup>
<!-- Embed Application and Domain DLLs into the NuGet package lib folder -->
<Target Name="IncludeReferencedProjectDlls" BeforeTargets="_GetPackageFiles" Condition="'$(TargetFramework)' != ''">
<ItemGroup>
<None Include="..\DocumentService.Application\bin\$(Configuration)\$(TargetFramework)\DocumentService.Application.dll"
Pack="true" PackagePath="lib\$(TargetFramework)\" Visible="false" />
<None Include="..\DocumentService.Domain\bin\$(Configuration)\$(TargetFramework)\DocumentService.Domain.dll"
Pack="true" PackagePath="lib\$(TargetFramework)\" Visible="false" />
</ItemGroup>
</Target>
<!-- Trigger inner build per-TFM to pick up IncludeReferencedProjectDlls target -->
<PropertyGroup>
<TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage</TargetsForTfmSpecificBuildOutput>
</PropertyGroup>
<Target Name="CopyProjectReferencesToPackage" DependsOnTargets="ResolveReferences">
<ItemGroup>
<BuildOutputInPackage Include="@(ReferenceCopyLocalPaths->WithMetadataValue('ReferenceSourceTarget','ProjectReference'))"
Condition="'%(Filename)' == 'DocumentService.Application' Or '%(Filename)' == 'DocumentService.Domain'" />
</ItemGroup>
</Target>
</Project> </Project>

View File

@@ -58,6 +58,9 @@ public static class ServiceCollectionExtensions
services.AddHttpClient<IPdfConversionClient, PdfConversionClient>(ConfigureClient) services.AddHttpClient<IPdfConversionClient, PdfConversionClient>(ConfigureClient)
.ConfigurePrimaryHttpMessageHandler(configureHandler); .ConfigurePrimaryHttpMessageHandler(configureHandler);
// WorkflowsClient composes existing clients – no HttpClient needed.
services.AddScoped<IWorkflowsClient, WorkflowsClient>();
return services; return services;
} }
} }

View File

@@ -14,7 +14,7 @@ public interface IPdfAttachmentClient
/// <param name="pdfStream">PDF file stream</param> /// <param name="pdfStream">PDF file stream</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Attachment check result with metadata</returns> /// <returns>Attachment check result with metadata</returns>
Task<AttachmentCheckResult> CheckAttachmentsAsync(Stream pdfStream, CancellationToken cancellationToken = default); Task<AttachmentCheckResult> CheckAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Checks if PDF contains attachments (Base64). /// Checks if PDF contains attachments (Base64).
@@ -22,7 +22,7 @@ public interface IPdfAttachmentClient
/// <param name="pdfBytes">PDF file as byte array</param> /// <param name="pdfBytes">PDF file as byte array</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Attachment check result with metadata</returns> /// <returns>Attachment check result with metadata</returns>
Task<AttachmentCheckResult> CheckAttachmentsAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<AttachmentCheckResult> CheckAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts all embedded attachments from a PDF (multipart). /// Extracts all embedded attachments from a PDF (multipart).
@@ -31,7 +31,7 @@ public interface IPdfAttachmentClient
/// <param name="pdfStream">PDF file stream</param> /// <param name="pdfStream">PDF file stream</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Dictionary of file name ? content stream for each extracted attachment</returns> /// <returns>Dictionary of file name ? content stream for each extracted attachment</returns>
Task<Dictionary<string, Stream>> ExtractAttachmentsAsync(Stream pdfStream, CancellationToken cancellationToken = default); Task<Dictionary<string, Stream>> ExtractAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts all embedded attachments from a PDF (Base64). /// Extracts all embedded attachments from a PDF (Base64).
@@ -40,7 +40,7 @@ public interface IPdfAttachmentClient
/// <param name="pdfBytes">PDF file as byte array</param> /// <param name="pdfBytes">PDF file as byte array</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Dictionary of file name ? content stream for each extracted attachment</returns> /// <returns>Dictionary of file name ? content stream for each extracted attachment</returns>
Task<Dictionary<string, Stream>> ExtractAttachmentsAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<Dictionary<string, Stream>> ExtractAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Adds attachments to PDF (multipart). ?? Not implemented yet in API. /// Adds attachments to PDF (multipart). ?? Not implemented yet in API.
@@ -50,7 +50,7 @@ public interface IPdfAttachmentClient
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Modified PDF as stream</returns> /// <returns>Modified PDF as stream</returns>
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
Task<Stream> AddAttachmentsAsync(Stream pdfStream, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default); Task<Stream> AddAsync(Stream pdfStream, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Adds attachments to PDF (Base64). ?? Not implemented yet in API. /// Adds attachments to PDF (Base64). ?? Not implemented yet in API.
@@ -60,5 +60,5 @@ public interface IPdfAttachmentClient
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Modified PDF as stream</returns> /// <returns>Modified PDF as stream</returns>
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
Task<Stream> AddAttachmentsAsync(byte[] pdfBytes, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default); Task<Stream> AddAsync(byte[] pdfBytes, List<AttachmentRequestDto> attachments, CancellationToken cancellationToken = default);
} }

View File

@@ -17,7 +17,7 @@ public interface IPdfConversionClient
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF/A document as stream</returns> /// <returns>PDF/A document as stream</returns>
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
Task<Stream> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default); Task<Stream> ToPdfAAsync(Stream pdfStream, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Converts a standard PDF to PDF/A format (Base64). /// Converts a standard PDF to PDF/A format (Base64).
@@ -27,7 +27,7 @@ public interface IPdfConversionClient
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF/A document as stream</returns> /// <returns>PDF/A document as stream</returns>
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
Task<Stream> ConvertToPdfAAsync(byte[] pdfBytes, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default); Task<Stream> ToPdfAAsync(byte[] pdfBytes, string pdfALevel = "PDF/A-3b", CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Converts a PDF/A document to standard PDF (multipart). /// Converts a PDF/A document to standard PDF (multipart).
@@ -36,7 +36,7 @@ public interface IPdfConversionClient
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Standard PDF as stream</returns> /// <returns>Standard PDF as stream</returns>
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
Task<Stream> ConvertFromPdfAAsync(Stream pdfStream, CancellationToken cancellationToken = default); Task<Stream> FromPdfAAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Converts a PDF/A document to standard PDF (Base64). /// Converts a PDF/A document to standard PDF (Base64).
@@ -45,5 +45,5 @@ public interface IPdfConversionClient
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Standard PDF as stream</returns> /// <returns>Standard PDF as stream</returns>
[Obsolete("API endpoint not implemented yet")] [Obsolete("API endpoint not implemented yet")]
Task<Stream> ConvertFromPdfAAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<Stream> FromPdfAAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
} }

View File

@@ -13,7 +13,7 @@ public interface IPdfValidationClient
/// <param name="pdfStream">PDF file stream</param> /// <param name="pdfStream">PDF file stream</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Validation result with metadata</returns> /// <returns>Validation result with metadata</returns>
Task<PdfValidationResult> ValidatePdfAsync(Stream pdfStream, CancellationToken cancellationToken = default); Task<PdfValidationResult> ValidateAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Validates a PDF from byte array (Base64 JSON). /// Validates a PDF from byte array (Base64 JSON).
@@ -21,7 +21,7 @@ public interface IPdfValidationClient
/// <param name="pdfBytes">PDF file as byte array</param> /// <param name="pdfBytes">PDF file as byte array</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Validation result with metadata</returns> /// <returns>Validation result with metadata</returns>
Task<PdfValidationResult> ValidatePdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<PdfValidationResult> ValidateAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Validates a PDF/A from stream (multipart upload). /// Validates a PDF/A from stream (multipart upload).

View File

@@ -14,7 +14,7 @@ public interface ISwissQrCodeClient
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param> /// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Swiss QR Code extraction result</returns> /// <returns>Swiss QR Code extraction result</returns>
Task<SwissQrCodeExtractionResult> ExtractSwissQrCodeAsync(Stream pdfStream, bool raw = false, CancellationToken cancellationToken = default); Task<SwissQrCodeExtractionResult> ExtractAsync(Stream pdfStream, bool raw = false, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts Swiss QR Code from PDF (Base64). /// Extracts Swiss QR Code from PDF (Base64).
@@ -23,6 +23,6 @@ public interface ISwissQrCodeClient
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param> /// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Swiss QR Code extraction result</returns> /// <returns>Swiss QR Code extraction result</returns>
Task<SwissQrCodeExtractionResult> ExtractSwissQrCodeAsync(byte[] pdfBytes, bool raw = false, CancellationToken cancellationToken = default); Task<SwissQrCodeExtractionResult> ExtractAsync(byte[] pdfBytes, bool raw = false, CancellationToken cancellationToken = default);
} }

View File

@@ -0,0 +1,71 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Results;
namespace DocumentService.Client.Interfaces;
/// <summary>
/// Orchestrated multi-step workflows that combine multiple DocumentService operations into
/// a single, higher-level call. Prefer these over manually chaining individual clients.
/// </summary>
public interface IWorkflowsClient
{
/// <summary>
/// Validates the PDF and attempts Swiss QR Code extraction in a single call,
/// returning both results together. The method never throws on invalid documents –
/// the caller receives the full <see cref="PdfValidationResult"/> regardless of outcome,
/// enabling detailed logging, diagnostics, or batch processing without try/catch.
/// </summary>
/// <param name="pdfBytes">PDF file as byte array.</param>
/// <param name="raw">
/// <c>false</c> (default) – returns a parsed <see cref="SwissQrCodeExtractionResult.Bill"/> object.<br/>
/// <c>true</c> – returns raw QR text lines without parsing.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A <see cref="SwissQrCodeResult"/> containing the <see cref="PdfValidationResult"/>
/// (always populated) and the <see cref="SwissQrCodeExtractionResult"/> (<c>null</c> if the
/// document was invalid).
/// </returns>
Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
byte[] pdfBytes,
bool raw = false,
CancellationToken cancellationToken = default);
/// <inheritdoc cref="InspectSwissQrCodeAsync(byte[], bool, CancellationToken)"/>
/// <param name="pdfStream">PDF file as a stream.</param>
/// <param name="raw"><inheritdoc cref="InspectSwissQrCodeAsync(byte[], bool, CancellationToken)" path="/param[@name=''raw'']"/></param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
Stream pdfStream,
bool raw = false,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Convenience extensions for <see cref="IWorkflowsClient"/> that accept a file path as input.
/// Defined alongside <see cref="IWorkflowsClient"/> so they are discoverable without an additional using directive.
/// </summary>
public static class WorkflowsClientExtensions
{
/// <summary>
/// Validates the PDF at <paramref name="filePath"/> and attempts Swiss QR Code extraction
/// in a single call. Opens a <see cref="FileStream"/> and delegates to
/// <see cref="IWorkflowsClient.InspectSwissQrCodeAsync(Stream, bool, CancellationToken)"/>.
/// </summary>
/// <param name="client">The workflows client.</param>
/// <param name="filePath">Absolute or relative path to the PDF file.</param>
/// <param name="raw">
/// <c>false</c> (default) – returns a parsed Bill object.<br/>
/// <c>true</c> – returns raw QR text lines without parsing.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
this IWorkflowsClient client,
string filePath,
bool raw = false,
CancellationToken cancellationToken = default)
{
using var stream = File.OpenRead(filePath);
return await client.InspectSwissQrCodeAsync(stream, raw, cancellationToken);
}
}

View File

@@ -14,7 +14,7 @@ public interface IZugferdClient
/// <param name="pdfStream">PDF file stream</param> /// <param name="pdfStream">PDF file stream</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD check result</returns> /// <returns>ZUGFeRD check result</returns>
Task<ZugferdCheckResult> HasZugferdAsync(Stream pdfStream, CancellationToken cancellationToken = default); Task<ZugferdCheckResult> CheckAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Checks whether a PDF contains a ZUGFeRD XML attachment (Base64). /// Checks whether a PDF contains a ZUGFeRD XML attachment (Base64).
@@ -22,7 +22,7 @@ public interface IZugferdClient
/// <param name="pdfBytes">PDF file as byte array</param> /// <param name="pdfBytes">PDF file as byte array</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD check result</returns> /// <returns>ZUGFeRD check result</returns>
Task<ZugferdCheckResult> HasZugferdAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<ZugferdCheckResult> CheckAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts the ZUGFeRD XML from a PDF and returns the raw XML stream (multipart). /// Extracts the ZUGFeRD XML from a PDF and returns the raw XML stream (multipart).
@@ -30,7 +30,7 @@ public interface IZugferdClient
/// <param name="pdfStream">PDF file stream</param> /// <param name="pdfStream">PDF file stream</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD XML content as stream</returns> /// <returns>ZUGFeRD XML content as stream</returns>
Task<Stream> ExtractZugferdAsync(Stream pdfStream, CancellationToken cancellationToken = default); Task<Stream> ExtractAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts the ZUGFeRD XML from a PDF and returns the raw XML stream (Base64). /// Extracts the ZUGFeRD XML from a PDF and returns the raw XML stream (Base64).
@@ -38,7 +38,7 @@ public interface IZugferdClient
/// <param name="pdfBytes">PDF file as byte array</param> /// <param name="pdfBytes">PDF file as byte array</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD XML content as stream</returns> /// <returns>ZUGFeRD XML content as stream</returns>
Task<Stream> ExtractZugferdAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<Stream> ExtractAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts ZUGFeRD metadata and XML content as a structured result (multipart). /// Extracts ZUGFeRD metadata and XML content as a structured result (multipart).
@@ -46,7 +46,7 @@ public interface IZugferdClient
/// <param name="pdfStream">PDF file stream</param> /// <param name="pdfStream">PDF file stream</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Structured ZUGFeRD extraction result</returns> /// <returns>Structured ZUGFeRD extraction result</returns>
Task<ZugferdExtractionResult> ExtractZugferdAsResultAsync(Stream pdfStream, CancellationToken cancellationToken = default); Task<ZugferdExtractionResult> ExtractAsResultAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Extracts ZUGFeRD metadata and XML content as a structured result (Base64). /// Extracts ZUGFeRD metadata and XML content as a structured result (Base64).
@@ -54,5 +54,5 @@ public interface IZugferdClient
/// <param name="pdfBytes">PDF file as byte array</param> /// <param name="pdfBytes">PDF file as byte array</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Structured ZUGFeRD extraction result</returns> /// <returns>Structured ZUGFeRD extraction result</returns>
Task<ZugferdExtractionResult> ExtractZugferdAsResultAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); Task<ZugferdExtractionResult> ExtractAsResultAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
} }

View File

@@ -0,0 +1,43 @@
using DocumentService.Application.Common.DTOs;
namespace DocumentService.Client.Models.Results;
/// <summary>
/// Combined result of a PDF validation followed by a Swiss QR Code extraction.
/// Both operations are always executed; <see cref="QrCode"/> is <c>null</c> when
/// the document fails validation.
/// </summary>
/// <param name="QrCode">
/// Extracted Swiss QR Code data, or <c>null</c> if the PDF was invalid or did not
/// contain a recognisable Swiss QR Code.
/// </param>
/// <param name="Validation">
/// PDF validation result. Always populated regardless of whether extraction succeeded.
/// Use <see cref="PdfValidationResult.IsValid"/>
/// to determine whether the extraction was attempted.
/// </param>
public record SwissQrCodeResult(
SwissQrCodeExtractionResult? QrCode,
PdfValidationResult Validation)
{
/// <summary>
/// Optional convenience method for code readability. Follows the standard
/// <c>TryXxx</c> pattern (e.g. <see cref="int.TryParse(string, out int)"/>,
/// <see cref="Dictionary{TKey,TValue}.TryGetValue"/>).
/// Use it when you prefer an <c>if/else</c> branch over a null-check on <see cref="QrCode"/>;
/// it is not required – both approaches are equivalent.
/// </summary>
/// <param name="qrCode">
/// When this method returns <c>true</c>, contains the extracted QR Code data.
/// When this method returns <c>false</c>, this value is <c>null</c> and must not be used.
/// </param>
/// <returns>
/// <c>true</c> if the PDF was valid and a Swiss QR Code was successfully extracted;
/// <c>false</c> otherwise.
/// </returns>
public bool TryExtract(out SwissQrCodeExtractionResult qrCode)
{
qrCode = QrCode ?? null!;
return Validation.IsValid && QrCode is not null;
}
}

View File

@@ -9,12 +9,13 @@
- **Separate Clients pro Fachbereich**: `Client.Validation`, `Client.Attachment`, `Client.Operations`, `Client.SwissQrCode`, `Client.Zugferd`, `Client.Conversion` - **Separate Clients pro Fachbereich**: `Client.Validation`, `Client.Attachment`, `Client.Operations`, `Client.SwissQrCode`, `Client.Zugferd`, `Client.Conversion`
- **Dualer Eingabe-Support**: Multipart (Stream) und Base64 (byte[]) für alle Endpunkte - **Dualer Eingabe-Support**: Multipart (Stream) und Base64 (byte[]) für alle Endpunkte
- **Stark typisierte Modelle**: Gemeinsame Request/Response-DTOs mit XML-Dokumentation - **Stark typisierte Modelle**: Gemeinsame Request/Response-DTOs mit XML-Dokumentation
- **Workflows**: `Client.Workflows` – kombinierte Operationen mit vorgelagerter Validierung (z. B. Swiss QR Code mit automatischer PDF-Prüfung)
## Installation ## Installation
Das Paket wird über einen internen NuGet-Feed bereitgestellt und ist **nicht** auf nuget.org verfügbar. Das Paket wird über einen internen NuGet-Feed bereitgestellt und ist **nicht** auf nuget.org verfügbar.
**NuGet-Paket:** `http://172.24.12.35:5000/packages/documentservice.client/1.0.0` **NuGet-Paket:** `http://172.24.12.35:5000/packages/documentservice.client/1.1.0`
**API-Basis-URL:** `http://172.24.12.39:9393/` **API-Basis-URL:** `http://172.24.12.39:9393/`
**Swagger-Dokumentation:** [http://172.24.12.39:9393/swagger/index.html](http://172.24.12.39:9393/swagger/index.html) **Swagger-Dokumentation:** [http://172.24.12.39:9393/swagger/index.html](http://172.24.12.39:9393/swagger/index.html)
@@ -30,8 +31,8 @@ Für PowerShell-Skripte ohne zugehöriges .NET-Projekt muss die Assembly manuell
```powershell ```powershell
# NuGet-Paket herunterladen (.nupkg ist ein ZIP-Archiv) # NuGet-Paket herunterladen (.nupkg ist ein ZIP-Archiv)
$nupkgUrl = "http://172.24.12.35:5000/packages/documentservice.client/1.0.0" $nupkgUrl = "http://172.24.12.35:5000/packages/documentservice.client/1.1.0"
$nupkgPath = "$env:TEMP\DocumentService.Client.1.0.0.nupkg" $nupkgPath = "$env:TEMP\DocumentService.Client.1.1.0.nupkg"
$extractDir = "$env:TEMP\DocumentService.Client" $extractDir = "$env:TEMP\DocumentService.Client"
Invoke-WebRequest -Uri $nupkgUrl -OutFile $nupkgPath Invoke-WebRequest -Uri $nupkgUrl -OutFile $nupkgPath
@@ -155,6 +156,7 @@ Client.OnReconfigure = OnReconfigure.Ignore
| `Client.SwissQrCode` | `ISwissQrCodeClient` | Schweizer QR-Code | | `Client.SwissQrCode` | `ISwissQrCodeClient` | Schweizer QR-Code |
| `Client.Zugferd` | `IZugferdClient` | ZUGFeRD-Rechnungen | | `Client.Zugferd` | `IZugferdClient` | ZUGFeRD-Rechnungen |
| `Client.Conversion` | `IPdfConversionClient` | PDF-Konvertierung *(geplant)* | | `Client.Conversion` | `IPdfConversionClient` | PDF-Konvertierung *(geplant)* |
| `Client.Workflows` | `IWorkflowsClient` | Kombinierte Operationen |
Jeder Zugriff auf eine dieser Eigenschaften erstellt einen neuen DI-Scope. Clients sind **nicht** für die dauerhafte Speicherung als Felder gedacht – jeder Aufruf holt sich eine frische Instanz. Jeder Zugriff auf eine dieser Eigenschaften erstellt einen neuen DI-Scope. Clients sind **nicht** für die dauerhafte Speicherung als Felder gedacht – jeder Aufruf holt sich eine frische Instanz.
@@ -179,6 +181,97 @@ Alle Beispiele nutzen ausschließlich den statischen `Client`-Einstiegspunkt. Vo
--- ---
### Workflows – Kombinierte Operationen
`Client.Workflows` fasst häufig benötigte Abläufe zusammen, die mehrere Einzelendpunkte orchestrieren. Validierung und Extraktion laufen in einem einzigen Aufruf – das Ergebnis enthält immer beide Informationen.
> Für Anwendungsfälle wie den **eParser** (Swiss QR Bill aus Eingangsrechnungen lesen) ist dies der empfohlene Einstiegspunkt.
#### Swiss QR Code – Validierung und Extraktion in einem Schritt
`InspectSwissQrCodeAsync` gibt immer ein `SwissQrCodeResult` zurück, das sowohl das Validierungsergebnis als auch den extrahierten QR-Code enthält. Für den Zugriff auf den QR-Code stehen zwei gleichwertige Varianten zur Verfügung:
```csharp
using DocumentService.Client;
using DocumentService.Client.Models.Results;
byte[] pdfBytes = await File.ReadAllBytesAsync("rechnung.pdf");
SwissQrCodeResult result = await Client.Workflows.InspectSwissQrCodeAsync(pdfBytes);
// Validierungsergebnis ist immer verfügbar
Console.WriteLine($"Gültig: {result.Validation.IsValid}");
Console.WriteLine($"Seiten: {result.Validation.PageCount}");
Console.WriteLine($"Verschlüsselt: {result.Validation.IsEncrypted}");
// Variante A: TryExtract – lesbarerer Code, kein expliziter Null-Check notwendig
// (optionale Hilfsmethode; Variante B ist gleichwertig)
if (result.TryExtract(out var qrCode))
{
Console.WriteLine($"Betrag: {qrCode.Bill?.Amount} {qrCode.Bill?.Currency}");
Console.WriteLine($"Empfänger: {qrCode.Bill?.Creditor?.Name}");
}
else
{
Console.WriteLine("Kein Swiss QR Code gefunden oder Dokument ungültig.");
}
// Variante B: direkter Null-Check auf QrCode
if (result.QrCode is not null)
{
Console.WriteLine($"Betrag: {result.QrCode.Bill?.Amount} {result.QrCode.Bill?.Currency}");
}
```
```vbnet
Imports DocumentService.Client
Imports DocumentService.Client.Models.Results
Dim pdfBytes = System.IO.File.ReadAllBytes("rechnung.pdf")
Dim result As SwissQrCodeResult = Await Client.Workflows.InspectSwissQrCodeAsync(pdfBytes)
Console.WriteLine($"Gültig: {result.Validation.IsValid}")
Console.WriteLine($"Seiten: {result.Validation.PageCount}")
Console.WriteLine($"Verschlüsselt: {result.Validation.IsEncrypted}")
' Variante A: TryExtract
Dim qrCode As DocumentService.Application.Common.DTOs.SwissQrCodeExtractionResult = Nothing
If result.TryExtract(qrCode) Then
Console.WriteLine($"Betrag: {qrCode.Bill?.Amount} {qrCode.Bill?.Currency}")
Console.WriteLine($"Empfänger: {qrCode.Bill?.Creditor?.Name}")
Else
Console.WriteLine("Kein Swiss QR Code gefunden oder Dokument ungültig.")
End If
' Variante B: direkter Null-Check
If result.QrCode IsNot Nothing Then
Console.WriteLine($"Betrag: {result.QrCode.Bill?.Amount} {result.QrCode.Bill?.Currency}")
End If
```
```powershell
Add-Type -Path "DocumentService.Client.dll"
$pdfBytes = [System.IO.File]::ReadAllBytes("rechnung.pdf")
$result = [DocumentService.Client.Client]::Workflows.InspectSwissQrCodeAsync($pdfBytes).GetAwaiter().GetResult()
Write-Host "Gueltig: $($result.Validation.IsValid)"
Write-Host "Seiten: $($result.Validation.PageCount)"
Write-Host "Verschluesselt:$($result.Validation.IsEncrypted)"
# TryExtract ist in PowerShell nicht direkt nutzbar (out-Parameter).
# Direkter Null-Check ist hier die einzige Option:
if ($null -ne $result.QrCode) {
Write-Host "Betrag: $($result.QrCode.Bill.Amount) $($result.QrCode.Bill.Currency)"
Write-Host "Empfaenger:$($result.QrCode.Bill.Creditor.Name)"
} else {
Write-Host "Kein Swiss QR Code gefunden oder Dokument ungueltig."
}
```
> **`TryExtract` – Hinweis:** Die Methode ist eine optionale Lesbarkeits-Hilfe und nicht zwingend erforderlich. Der direkte Null-Check auf `result.QrCode` ist gleichwertig. Gibt `TryExtract` `false` zurück, ist der `out`-Parameter `qrCode` `null` – er darf in diesem Fall nicht verwendet werden.
---
### 1. PDF-Validierung ### 1. PDF-Validierung
```csharp ```csharp
@@ -186,7 +279,7 @@ using DocumentService.Client;
// Variante A: Stream (multipart/form-data – empfohlen für große Dateien) // Variante A: Stream (multipart/form-data – empfohlen für große Dateien)
using var stream = File.OpenRead("rechnung.pdf"); using var stream = File.OpenRead("rechnung.pdf");
var result = await Client.Validation.ValidatePdfAsync(stream); var result = await Client.Validation.ValidateAsync(stream);
Console.WriteLine($"Seiten: {result.PageCount}"); Console.WriteLine($"Seiten: {result.PageCount}");
Console.WriteLine($"PDF-Version: {result.PdfVersion}"); Console.WriteLine($"PDF-Version: {result.PdfVersion}");
@@ -196,7 +289,7 @@ Console.WriteLine($"Dateigröße: {result.FileSizeMB:F2} MB");
// Variante B: byte[] (Base64 JSON) // Variante B: byte[] (Base64 JSON)
byte[] pdfBytes = await File.ReadAllBytesAsync("rechnung.pdf"); byte[] pdfBytes = await File.ReadAllBytesAsync("rechnung.pdf");
var result2 = await Client.Validation.ValidatePdfAsync(pdfBytes); var result2 = await Client.Validation.ValidateAsync(pdfBytes);
``` ```
```vbnet ```vbnet
@@ -204,7 +297,7 @@ Imports DocumentService.Client
' Variante A: Stream ' Variante A: Stream
Dim stream As New System.IO.FileStream("rechnung.pdf", System.IO.FileMode.Open) Dim stream As New System.IO.FileStream("rechnung.pdf", System.IO.FileMode.Open)
Dim result = Await Client.Validation.ValidatePdfAsync(stream) Dim result = Await Client.Validation.ValidateAsync(stream)
stream.Dispose() stream.Dispose()
Console.WriteLine($"Seiten: {result.PageCount}") Console.WriteLine($"Seiten: {result.PageCount}")
@@ -215,7 +308,7 @@ Console.WriteLine($"Dateigröße: {result.FileSizeMB:F2} MB")
' Variante B: byte[] ' Variante B: byte[]
Dim pdfBytes = System.IO.File.ReadAllBytes("rechnung.pdf") Dim pdfBytes = System.IO.File.ReadAllBytes("rechnung.pdf")
Dim result2 = Await Client.Validation.ValidatePdfAsync(pdfBytes) Dim result2 = Await Client.Validation.ValidateAsync(pdfBytes)
``` ```
```powershell ```powershell
@@ -223,7 +316,7 @@ Add-Type -Path "DocumentService.Client.dll"
# Variante A: Stream # Variante A: Stream
$stream = [System.IO.File]::OpenRead("rechnung.pdf") $stream = [System.IO.File]::OpenRead("rechnung.pdf")
$result = [DocumentService.Client.Client]::Validation.ValidatePdfAsync($stream).GetAwaiter().GetResult() $result = [DocumentService.Client.Client]::Validation.ValidateAsync($stream).GetAwaiter().GetResult()
$stream.Dispose() $stream.Dispose()
Write-Host "Seiten: $($result.PageCount)" Write-Host "Seiten: $($result.PageCount)"
@@ -234,13 +327,22 @@ Write-Host "Dateigroesse: $($result.FileSizeMB.ToString('F2')) MB"
# Variante B: byte[] # Variante B: byte[]
$pdfBytes = [System.IO.File]::ReadAllBytes("rechnung.pdf") $pdfBytes = [System.IO.File]::ReadAllBytes("rechnung.pdf")
$result2 = [DocumentService.Client.Client]::Validation.ValidatePdfAsync($pdfBytes).GetAwaiter().GetResult() $result2 = [DocumentService.Client.Client]::Validation.ValidateAsync($pdfBytes).GetAwaiter().GetResult()
``` ```
--- ---
#### PDF/A-Konformitätsprüfung #### PDF/A-Konformitätsprüfung
> **⚠️ Hinweis zur Validierungstiefe:** `ValidatePdfAAsync` führt eine **heuristische PDF/A-Erkennung** durch, keine vollständige ISO-19005-Konformitätsprüfung. Die Implementierung erkennt das PDF/A-Konformitätslevel anhand der XMP-Metadaten (`pdfaid:part`, `pdfaid:conformance`) und prüft auf Verschlüsselung. Folgende Anforderungen der Norm werden **nicht** geprüft:
>
> - Einbettung aller verwendeten Schriftarten
> - Gültigkeit und Vollständigkeit der Farbprofile (ICC)
> - Fehlen von JavaScript, eingebetteten Multimedia-Inhalten und externen Referenzen
> - Strukturelle Barrierefreiheit (Level A)
>
> Das Ergebnis `IsValid = true` bedeutet daher: **„Keine erkannten Verstöße"**, nicht **„nachgewiesene ISO-19005-Konformität"**. Für eine zertifizierungsfähige Prüfung ist ein dedizierter Validator (z. B. veraPDF) erforderlich.
```csharp ```csharp
using DocumentService.Client; using DocumentService.Client;
@@ -324,7 +426,7 @@ if ($result.Warnings.Count -gt 0) {
using DocumentService.Client; using DocumentService.Client;
byte[] pdfBytes = await File.ReadAllBytesAsync("dokument.pdf"); byte[] pdfBytes = await File.ReadAllBytesAsync("dokument.pdf");
var check = await Client.Attachment.CheckAttachmentsAsync(pdfBytes); var check = await Client.Attachment.CheckAsync(pdfBytes);
Console.WriteLine($"Hat Anhänge: {check.HasAttachments}"); Console.WriteLine($"Hat Anhänge: {check.HasAttachments}");
Console.WriteLine($"Anzahl: {check.AttachmentCount}"); Console.WriteLine($"Anzahl: {check.AttachmentCount}");
@@ -337,7 +439,7 @@ foreach (var attachment in check.Attachments)
Imports DocumentService.Client Imports DocumentService.Client
Dim pdfBytes = System.IO.File.ReadAllBytes("dokument.pdf") Dim pdfBytes = System.IO.File.ReadAllBytes("dokument.pdf")
Dim check = Await Client.Attachment.CheckAttachmentsAsync(pdfBytes) Dim check = Await Client.Attachment.CheckAsync(pdfBytes)
Console.WriteLine($"Hat Anhänge: {check.HasAttachments}") Console.WriteLine($"Hat Anhänge: {check.HasAttachments}")
Console.WriteLine($"Anzahl: {check.AttachmentCount}") Console.WriteLine($"Anzahl: {check.AttachmentCount}")
@@ -351,7 +453,7 @@ Next
Add-Type -Path "DocumentService.Client.dll" Add-Type -Path "DocumentService.Client.dll"
$pdfBytes = [System.IO.File]::ReadAllBytes("dokument.pdf") $pdfBytes = [System.IO.File]::ReadAllBytes("dokument.pdf")
$check = [DocumentService.Client.Client]::Attachment.CheckAttachmentsAsync($pdfBytes).GetAwaiter().GetResult() $check = [DocumentService.Client.Client]::Attachment.CheckAsync($pdfBytes).GetAwaiter().GetResult()
Write-Host "Hat Anhaenge: $($check.HasAttachments)" Write-Host "Hat Anhaenge: $($check.HasAttachments)"
Write-Host "Anzahl: $($check.AttachmentCount)" Write-Host "Anzahl: $($check.AttachmentCount)"
@@ -369,7 +471,7 @@ Rückgabe: `Dictionary<string, Stream>` – Schlüssel = ursprünglicher Dateina
using DocumentService.Client; using DocumentService.Client;
using var pdfStream = File.OpenRead("dokument_mit_anhaengen.pdf"); using var pdfStream = File.OpenRead("dokument_mit_anhaengen.pdf");
var files = await Client.Attachment.ExtractAttachmentsAsync(pdfStream); var files = await Client.Attachment.ExtractAsync(pdfStream);
foreach (var (fileName, fileStream) in files) foreach (var (fileName, fileStream) in files)
{ {
@@ -386,7 +488,7 @@ foreach (var (fileName, fileStream) in files)
Imports DocumentService.Client Imports DocumentService.Client
Dim pdfStream As New System.IO.FileStream("dokument_mit_anhaengen.pdf", System.IO.FileMode.Open) Dim pdfStream As New System.IO.FileStream("dokument_mit_anhaengen.pdf", System.IO.FileMode.Open)
Dim files = Await Client.Attachment.ExtractAttachmentsAsync(pdfStream) Dim files = Await Client.Attachment.ExtractAsync(pdfStream)
pdfStream.Dispose() pdfStream.Dispose()
For Each entry In files For Each entry In files
@@ -405,7 +507,7 @@ Next
Add-Type -Path "DocumentService.Client.dll" Add-Type -Path "DocumentService.Client.dll"
$pdfStream = [System.IO.File]::OpenRead("dokument_mit_anhaengen.pdf") $pdfStream = [System.IO.File]::OpenRead("dokument_mit_anhaengen.pdf")
$files = [DocumentService.Client.Client]::Attachment.ExtractAttachmentsAsync($pdfStream).GetAwaiter().GetResult() $files = [DocumentService.Client.Client]::Attachment.ExtractAsync($pdfStream).GetAwaiter().GetResult()
$pdfStream.Dispose() $pdfStream.Dispose()
New-Item -ItemType Directory -Force -Path "output" | Out-Null New-Item -ItemType Directory -Force -Path "output" | Out-Null
@@ -424,7 +526,7 @@ foreach ($entry in $files.GetEnumerator()) {
} }
``` ```
> **Hinweis – `AddAttachmentsAsync`:** Das Einbetten von Anhängen in PDF/A-3-Dokumente ist für eine zukünftige Version geplant. Die Methode ist bereits im Interface definiert, jedoch als `[Obsolete]` markiert und wirft zur Laufzeit eine `NotImplementedException`. Der API-Endpunkt `POST /api/pdf/attachments/add` ist noch nicht implementiert. > **Hinweis – `AddAsync`:** Das Einbetten von Anhängen in PDF/A-3-Dokumente ist für eine zukünftige Version geplant. Die Methode ist bereits im Interface definiert, jedoch als `[Obsolete]` markiert und wirft zur Laufzeit eine `NotImplementedException`. Der API-Endpunkt `POST /api/pdf/attachments/add` ist noch nicht implementiert.
--- ---
@@ -743,7 +845,7 @@ using DocumentService.Client;
// Geparste Rechnungsstruktur gemäß Swiss QR Bill Standard 2.0 // Geparste Rechnungsstruktur gemäß Swiss QR Bill Standard 2.0
byte[] pdfBytes = await File.ReadAllBytesAsync("qr-rechnung.pdf"); byte[] pdfBytes = await File.ReadAllBytesAsync("qr-rechnung.pdf");
var result = await Client.SwissQrCode.ExtractSwissQrCodeAsync(pdfBytes, raw: false); var result = await Client.SwissQrCode.ExtractAsync(pdfBytes, raw: false);
Console.WriteLine($"Betrag: {result.Bill?.Amount} {result.Bill?.Currency}"); Console.WriteLine($"Betrag: {result.Bill?.Amount} {result.Bill?.Currency}");
Console.WriteLine($"Konto: {result.Bill?.Account}"); Console.WriteLine($"Konto: {result.Bill?.Account}");
@@ -756,7 +858,7 @@ Console.WriteLine($"Mitteilung:{result.Bill?.UnstructuredMessage}");
Imports DocumentService.Client Imports DocumentService.Client
Dim pdfBytes = System.IO.File.ReadAllBytes("qr-rechnung.pdf") Dim pdfBytes = System.IO.File.ReadAllBytes("qr-rechnung.pdf")
Dim result = Await Client.SwissQrCode.ExtractSwissQrCodeAsync(pdfBytes, raw:=False) Dim result = Await Client.SwissQrCode.ExtractAsync(pdfBytes, raw:=False)
Console.WriteLine($"Betrag: {result.Bill?.Amount} {result.Bill?.Currency}") Console.WriteLine($"Betrag: {result.Bill?.Amount} {result.Bill?.Currency}")
Console.WriteLine($"Konto: {result.Bill?.Account}") Console.WriteLine($"Konto: {result.Bill?.Account}")
@@ -769,7 +871,7 @@ Console.WriteLine($"Mitteilung:{result.Bill?.UnstructuredMessage}")
Add-Type -Path "DocumentService.Client.dll" Add-Type -Path "DocumentService.Client.dll"
$pdfBytes = [System.IO.File]::ReadAllBytes("qr-rechnung.pdf") $pdfBytes = [System.IO.File]::ReadAllBytes("qr-rechnung.pdf")
$result = [DocumentService.Client.Client]::SwissQrCode.ExtractSwissQrCodeAsync($pdfBytes, $false).GetAwaiter().GetResult() $result = [DocumentService.Client.Client]::SwissQrCode.ExtractAsync($pdfBytes, $false).GetAwaiter().GetResult()
Write-Host "Betrag: $($result.Bill.Amount) $($result.Bill.Currency)" Write-Host "Betrag: $($result.Bill.Amount) $($result.Bill.Currency)"
Write-Host "Konto: $($result.Bill.Account)" Write-Host "Konto: $($result.Bill.Account)"
@@ -785,7 +887,7 @@ Write-Host "Mitteilung:$($result.Bill.UnstructuredMessage)"
```csharp ```csharp
// Ohne Parsing – direkt aus dem QR-Code-Inhalt // Ohne Parsing – direkt aus dem QR-Code-Inhalt
using var pdfStream = File.OpenRead("qr-rechnung.pdf"); using var pdfStream = File.OpenRead("qr-rechnung.pdf");
var result = await Client.SwissQrCode.ExtractSwissQrCodeAsync(pdfStream, raw: true); var result = await Client.SwissQrCode.ExtractAsync(pdfStream, raw: true);
foreach (var line in result.RawLines) foreach (var line in result.RawLines)
Console.WriteLine(line); Console.WriteLine(line);
@@ -793,7 +895,7 @@ foreach (var line in result.RawLines)
```vbnet ```vbnet
Dim pdfStream As New System.IO.FileStream("qr-rechnung.pdf", System.IO.FileMode.Open) Dim pdfStream As New System.IO.FileStream("qr-rechnung.pdf", System.IO.FileMode.Open)
Dim result = Await Client.SwissQrCode.ExtractSwissQrCodeAsync(pdfStream, raw:=True) Dim result = Await Client.SwissQrCode.ExtractAsync(pdfStream, raw:=True)
pdfStream.Dispose() pdfStream.Dispose()
For Each line In result.RawLines For Each line In result.RawLines
@@ -803,7 +905,7 @@ Next
```powershell ```powershell
$pdfStream = [System.IO.File]::OpenRead("qr-rechnung.pdf") $pdfStream = [System.IO.File]::OpenRead("qr-rechnung.pdf")
$result = [DocumentService.Client.Client]::SwissQrCode.ExtractSwissQrCodeAsync($pdfStream, $true).GetAwaiter().GetResult() $result = [DocumentService.Client.Client]::SwissQrCode.ExtractAsync($pdfStream, $true).GetAwaiter().GetResult()
$pdfStream.Dispose() $pdfStream.Dispose()
foreach ($line in $result.RawLines) { Write-Host $line } foreach ($line in $result.RawLines) { Write-Host $line }
@@ -821,7 +923,7 @@ foreach ($line in $result.RawLines) { Write-Host $line }
using DocumentService.Client; using DocumentService.Client;
byte[] pdfBytes = await File.ReadAllBytesAsync("eingangsrechnung.pdf"); byte[] pdfBytes = await File.ReadAllBytesAsync("eingangsrechnung.pdf");
var check = await Client.Zugferd.HasZugferdAsync(pdfBytes); var check = await Client.Zugferd.CheckAsync(pdfBytes);
Console.WriteLine($"Enthält ZUGFeRD: {check.HasZugferd}"); Console.WriteLine($"Enthält ZUGFeRD: {check.HasZugferd}");
@@ -837,7 +939,7 @@ if (check.HasZugferd)
Imports DocumentService.Client Imports DocumentService.Client
Dim pdfBytes = System.IO.File.ReadAllBytes("eingangsrechnung.pdf") Dim pdfBytes = System.IO.File.ReadAllBytes("eingangsrechnung.pdf")
Dim check = Await Client.Zugferd.HasZugferdAsync(pdfBytes) Dim check = Await Client.Zugferd.CheckAsync(pdfBytes)
Console.WriteLine($"Enthält ZUGFeRD: {check.HasZugferd}") Console.WriteLine($"Enthält ZUGFeRD: {check.HasZugferd}")
@@ -852,7 +954,7 @@ End If
Add-Type -Path "DocumentService.Client.dll" Add-Type -Path "DocumentService.Client.dll"
$pdfBytes = [System.IO.File]::ReadAllBytes("eingangsrechnung.pdf") $pdfBytes = [System.IO.File]::ReadAllBytes("eingangsrechnung.pdf")
$check = [DocumentService.Client.Client]::Zugferd.HasZugferdAsync($pdfBytes).GetAwaiter().GetResult() $check = [DocumentService.Client.Client]::Zugferd.CheckAsync($pdfBytes).GetAwaiter().GetResult()
Write-Host "Enthaelt ZUGFeRD: $($check.HasZugferd)" Write-Host "Enthaelt ZUGFeRD: $($check.HasZugferd)"
@@ -867,7 +969,7 @@ if ($check.HasZugferd) {
```csharp ```csharp
using var pdfStream = File.OpenRead("eingangsrechnung.pdf"); using var pdfStream = File.OpenRead("eingangsrechnung.pdf");
using var xmlStream = await Client.Zugferd.ExtractZugferdAsync(pdfStream); using var xmlStream = await Client.Zugferd.ExtractAsync(pdfStream);
await using var output = File.Create("factur-x.xml"); await using var output = File.Create("factur-x.xml");
await xmlStream.CopyToAsync(output); await xmlStream.CopyToAsync(output);
@@ -876,7 +978,7 @@ Console.WriteLine("ZUGFeRD-XML gespeichert.");
```vbnet ```vbnet
Dim pdfStream As New System.IO.FileStream("eingangsrechnung.pdf", System.IO.FileMode.Open) Dim pdfStream As New System.IO.FileStream("eingangsrechnung.pdf", System.IO.FileMode.Open)
Dim xmlStream = Await Client.Zugferd.ExtractZugferdAsync(pdfStream) Dim xmlStream = Await Client.Zugferd.ExtractAsync(pdfStream)
pdfStream.Dispose() pdfStream.Dispose()
Using output = System.IO.File.Create("factur-x.xml") Using output = System.IO.File.Create("factur-x.xml")
@@ -888,7 +990,7 @@ Console.WriteLine("ZUGFeRD-XML gespeichert.")
```powershell ```powershell
$pdfStream = [System.IO.File]::OpenRead("eingangsrechnung.pdf") $pdfStream = [System.IO.File]::OpenRead("eingangsrechnung.pdf")
$xmlStream = [DocumentService.Client.Client]::Zugferd.ExtractZugferdAsync($pdfStream).GetAwaiter().GetResult() $xmlStream = [DocumentService.Client.Client]::Zugferd.ExtractAsync($pdfStream).GetAwaiter().GetResult()
$pdfStream.Dispose() $pdfStream.Dispose()
$output = [System.IO.File]::Create("factur-x.xml") $output = [System.IO.File]::Create("factur-x.xml")
@@ -902,7 +1004,7 @@ Write-Host "ZUGFeRD-XML gespeichert."
```csharp ```csharp
byte[] pdfBytes = await File.ReadAllBytesAsync("eingangsrechnung.pdf"); byte[] pdfBytes = await File.ReadAllBytesAsync("eingangsrechnung.pdf");
var result = await Client.Zugferd.ExtractZugferdAsResultAsync(pdfBytes); var result = await Client.Zugferd.ExtractAsResultAsync(pdfBytes);
Console.WriteLine($"Dateiname: {result.FileName}"); Console.WriteLine($"Dateiname: {result.FileName}");
Console.WriteLine($"Größe: {result.FileSize} Bytes"); Console.WriteLine($"Größe: {result.FileSize} Bytes");
@@ -914,7 +1016,7 @@ var doc = XDocument.Parse(result.XmlContent);
```vbnet ```vbnet
Dim pdfBytes = System.IO.File.ReadAllBytes("eingangsrechnung.pdf") Dim pdfBytes = System.IO.File.ReadAllBytes("eingangsrechnung.pdf")
Dim result = Await Client.Zugferd.ExtractZugferdAsResultAsync(pdfBytes) Dim result = Await Client.Zugferd.ExtractAsResultAsync(pdfBytes)
Console.WriteLine($"Dateiname: {result.FileName}") Console.WriteLine($"Dateiname: {result.FileName}")
Console.WriteLine($"Größe: {result.FileSize} Bytes") Console.WriteLine($"Größe: {result.FileSize} Bytes")
@@ -926,7 +1028,7 @@ Dim doc = System.Xml.Linq.XDocument.Parse(result.XmlContent)
```powershell ```powershell
$pdfBytes = [System.IO.File]::ReadAllBytes("eingangsrechnung.pdf") $pdfBytes = [System.IO.File]::ReadAllBytes("eingangsrechnung.pdf")
$result = [DocumentService.Client.Client]::Zugferd.ExtractZugferdAsResultAsync($pdfBytes).GetAwaiter().GetResult() $result = [DocumentService.Client.Client]::Zugferd.ExtractAsResultAsync($pdfBytes).GetAwaiter().GetResult()
Write-Host "Dateiname: $($result.FileName)" Write-Host "Dateiname: $($result.FileName)"
Write-Host "Groesse: $($result.FileSize) Bytes" Write-Host "Groesse: $($result.FileSize) Bytes"
@@ -949,20 +1051,20 @@ $doc = [System.Xml.Linq.XDocument]::Parse($result.XmlContent)
```csharp ```csharp
// NICHT VERWENDEN – wirft NotImplementedException // NICHT VERWENDEN – wirft NotImplementedException
#pragma warning disable CS0618 #pragma warning disable CS0618
using var pdfAStream = await Client.Conversion.ConvertToPdfAAsync(pdfStream, pdfALevel: "PDF/A-3b"); using var pdfAStream = await Client.Conversion.ToPdfAAsync(pdfStream, pdfALevel: "PDF/A-3b");
#pragma warning restore CS0618 #pragma warning restore CS0618
``` ```
```vbnet ```vbnet
' NICHT VERWENDEN – wirft NotImplementedException ' NICHT VERWENDEN – wirft NotImplementedException
#Disable Warning BC40000 #Disable Warning BC40000
Dim pdfAStream = Await Client.Conversion.ConvertToPdfAAsync(pdfStream, pdfALevel:="PDF/A-3b") Dim pdfAStream = Await Client.Conversion.ToPdfAAsync(pdfStream, pdfALevel:="PDF/A-3b")
#Enable Warning BC40000 #Enable Warning BC40000
``` ```
```powershell ```powershell
# NICHT VERWENDEN – wirft NotImplementedException # NICHT VERWENDEN – wirft NotImplementedException
$pdfAStream = [DocumentService.Client.Client]::Conversion.ConvertToPdfAAsync($pdfStream, "PDF/A-3b").GetAwaiter().GetResult() $pdfAStream = [DocumentService.Client.Client]::Conversion.ToPdfAAsync($pdfStream, "PDF/A-3b").GetAwaiter().GetResult()
``` ```
Sobald die Endpunkte auf dem Server bereitgestellt werden, entfällt das `[Obsolete]`-Attribut und die Methoden sind ohne weiteren Anpassungsbedarf auf Clientseite verwendbar. Sobald die Endpunkte auf dem Server bereitgestellt werden, entfällt das `[Obsolete]`-Attribut und die Methoden sind ohne weiteren Anpassungsbedarf auf Clientseite verwendbar.
@@ -973,20 +1075,21 @@ Sobald die Endpunkte auf dem Server bereitgestellt werden, entfällt das `[Obsol
| **Client-Eigenschaft** | **Methode** | **API-Endpunkt** | **Status** | **Beschreibung** | | **Client-Eigenschaft** | **Methode** | **API-Endpunkt** | **Status** | **Beschreibung** |
|---|---|---|---|---| |---|---|---|---|---|
| `Client.Validation` | `ValidatePdfAsync()` | `POST /api/pdf/validation/validate` | ✅ Verfügbar | PDF validieren, Metadaten abrufen | | `Client.Validation` | `ValidateAsync()` | `POST /api/pdf/validation/validate` | ✅ Verfügbar | PDF validieren, Metadaten abrufen |
| `Client.Validation` | `ValidatePdfAAsync()` | `POST /api/pdf/validation/validate-pdfa` | ✅ Verfügbar | PDF/A-Konformität prüfen | | `Client.Validation` | `ValidatePdfAAsync()` | `POST /api/pdf/validation/validate-pdfa` | ✅ Verfügbar | PDF/A-Erkennung (heuristisch, keine vollständige ISO-19005-Prüfung) |
| `Client.Attachment` | `CheckAttachmentsAsync()` | `POST /api/pdf/attachments/check` | ✅ Verfügbar | Eingebettete Anhänge erkennen | | `Client.Attachment` | `CheckAsync()` | `POST /api/pdf/attachments/check` | ✅ Verfügbar | Eingebettete Anhänge erkennen |
| `Client.Attachment` | `ExtractAttachmentsAsync()` | `POST /api/pdf/attachments/extract` | ✅ Verfügbar | Anhänge extrahieren als `Dictionary<string, Stream>` | | `Client.Attachment` | `ExtractAsync()` | `POST /api/pdf/attachments/extract` | ✅ Verfügbar | Anhänge extrahieren als `Dictionary<string, Stream>` |
| `Client.Attachment` | `AddAttachmentsAsync()` | `POST /api/pdf/attachments/add` | ⏳ Geplant | Anhänge in PDF/A-3 einbetten | | `Client.Attachment` | `AddAsync()` | `POST /api/pdf/attachments/add` | ⏳ Geplant | Anhänge in PDF/A-3 einbetten |
| `Client.Operations` | `MergeAsync()` | `POST /api/pdf/operations/merge` | ✅ Verfügbar | Mehrere PDFs zusammenführen | | `Client.Operations` | `MergeAsync()` | `POST /api/pdf/operations/merge` | ✅ Verfügbar | Mehrere PDFs zusammenführen |
| `Client.Operations` | `AnnotateAsync()` | `POST /api/pdf/operations/annotate` | ✅ Verfügbar | Annotationen hinzufügen | | `Client.Operations` | `AnnotateAsync()` | `POST /api/pdf/operations/annotate` | ✅ Verfügbar | Annotationen hinzufügen |
| `Client.Operations` | `StampAsync()` | `POST /api/pdf/operations/stamp` | ✅ Verfügbar | Text-/Bildstempel hinzufügen | | `Client.Operations` | `StampAsync()` | `POST /api/pdf/operations/stamp` | ✅ Verfügbar | Text-/Bildstempel hinzufügen |
| `Client.SwissQrCode` | `ExtractSwissQrCodeAsync()` | `POST /api/pdf/qr-code/extract-swiss` | ✅ Verfügbar | Schweizer QR-Code-Daten extrahieren | | `Client.SwissQrCode` | `ExtractAsync()` | `POST /api/pdf/qr-code/extract-swiss` | ✅ Verfügbar | Schweizer QR-Code-Daten extrahieren |
| `Client.Zugferd` | `HasZugferdAsync()` | `POST /api/pdf/zugferd/has-zugferd` | ✅ Verfügbar | ZUGFeRD-Einbettung prüfen | | `Client.Zugferd` | `CheckAsync()` | `POST /api/pdf/zugferd/has-zugferd` | ✅ Verfügbar | ZUGFeRD-Einbettung prüfen |
| `Client.Zugferd` | `ExtractZugferdAsync()` | `POST /api/pdf/zugferd/extract` | ✅ Verfügbar | ZUGFeRD-XML als Stream | | `Client.Zugferd` | `ExtractAsync()` | `POST /api/pdf/zugferd/extract` | ✅ Verfügbar | ZUGFeRD-XML als Stream |
| `Client.Zugferd` | `ExtractZugferdAsResultAsync()` | `POST /api/pdf/zugferd/extract` | ✅ Verfügbar | ZUGFeRD-XML mit Metadaten als Objekt | | `Client.Zugferd` | `ExtractAsResultAsync()` | `POST /api/pdf/zugferd/extract` | ✅ Verfügbar | ZUGFeRD-XML mit Metadaten als Objekt |
| `Client.Conversion` | `ConvertToPdfAAsync()` | `POST /api/pdf/conversion/to-pdfa` | ⏳ Geplant | PDF zu PDF/A konvertieren | | `Client.Conversion` | `ToPdfAAsync()` | `POST /api/pdf/conversion/to-pdfa` | ⏳ Geplant | PDF zu PDF/A konvertieren |
| `Client.Conversion` | `ConvertFromPdfAAsync()` | `POST /api/pdf/conversion/from-pdfa` | ⏳ Geplant | PDF/A-Einschränkungen aufheben | | `Client.Conversion` | `FromPdfAAsync()` | `POST /api/pdf/conversion/from-pdfa` | ⏳ Geplant | PDF/A-Einschränkungen aufheben |
| `Client.Workflows` | `InspectSwissQrCodeAsync()` | — | ✅ Verfügbar | Validierung + Swiss QR Code Extraktion; gibt immer `SwissQrCodeResult` zurück, wirft nie |
> **⏳ Geplant:** Der Client-Code ist vorhanden und kompiliert fehlerfrei. Ein Aufruf zur Laufzeit wirft jedoch `NotImplementedException`, da der serverseitige Endpunkt noch nicht existiert. Die Methoden sind mit `[Obsolete]` markiert, sodass der Compiler eine Build-Warnung ausgibt. > **⏳ Geplant:** Der Client-Code ist vorhanden und kompiliert fehlerfrei. Ein Aufruf zur Laufzeit wirft jedoch `NotImplementedException`, da der serverseitige Endpunkt noch nicht existiert. Die Methoden sind mit `[Obsolete]` markiert, sodass der Compiler eine Build-Warnung ausgibt.
@@ -1000,7 +1103,7 @@ using DocumentService.Client;
try try
{ {
using var stream = File.OpenRead("dokument.pdf"); using var stream = File.OpenRead("dokument.pdf");
var result = await Client.Validation.ValidatePdfAsync(stream); var result = await Client.Validation.ValidateAsync(stream);
Console.WriteLine($"Seiten: {result.PageCount}"); Console.WriteLine($"Seiten: {result.PageCount}");
} }
catch (InvalidOperationException ex) when (ex.Message.Contains("not configured")) catch (InvalidOperationException ex) when (ex.Message.Contains("not configured"))
@@ -1030,7 +1133,7 @@ Imports DocumentService.Client
Try Try
Dim stream As New System.IO.FileStream("dokument.pdf", System.IO.FileMode.Open) Dim stream As New System.IO.FileStream("dokument.pdf", System.IO.FileMode.Open)
Dim result = Await Client.Validation.ValidatePdfAsync(stream) Dim result = Await Client.Validation.ValidateAsync(stream)
stream.Dispose() stream.Dispose()
Console.WriteLine($"Seiten: {result.PageCount}") Console.WriteLine($"Seiten: {result.PageCount}")
Catch ex As InvalidOperationException When ex.Message.Contains("not configured") Catch ex As InvalidOperationException When ex.Message.Contains("not configured")
@@ -1049,7 +1152,7 @@ Add-Type -Path "DocumentService.Client.dll"
try { try {
$stream = [System.IO.File]::OpenRead("dokument.pdf") $stream = [System.IO.File]::OpenRead("dokument.pdf")
$result = [DocumentService.Client.Client]::Validation.ValidatePdfAsync($stream).GetAwaiter().GetResult() $result = [DocumentService.Client.Client]::Validation.ValidateAsync($stream).GetAwaiter().GetResult()
$stream.Dispose() $stream.Dispose()
Write-Host "Seiten: $($result.PageCount)" Write-Host "Seiten: $($result.PageCount)"
} }

View File

@@ -0,0 +1,235 @@
using DocumentService.Application.Common.DTOs;
using FluentAssertions;
namespace DocumentService.Tests.Live.API;
/// <summary>
/// Live tests for PDF attachment endpoints (check + extract).
/// Uses PdfAttachmentClient — routes managed by the client.
/// Target: http://172.24.12.39:9393/
/// </summary>
[Collection("Live API")]
public class PdfAttachmentLiveTests : LiveApiTestBase
{
// ──────────────────────────────────────────────────────────────
// CheckAsync(Stream) → multipart → /api/pdf/attachments/check
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Check_Stream_PdfWithoutAttachments_ReturnsHasAttachmentsFalse()
{
using var stream = LoadPdfStream("valid.pdf");
var result = await AttachmentClient.CheckAsync(stream);
result.Should().NotBeNull();
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
result.Attachments.Should().BeEmpty();
}
[Fact]
public async Task Check_Stream_PdfWith6Attachments_ReturnsAllAttachmentsWithMetadata()
{
using var stream = LoadPdfStream("pdfWithMoreThanOneAttachment.pdf");
var result = await AttachmentClient.CheckAsync(stream);
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(6, "PDF has exactly 6 attachments");
result.Attachments.Should().HaveCount(6);
foreach (var attachment in result.Attachments)
{
attachment.FileName.Should().NotBeNullOrEmpty();
attachment.MimeType.Should().NotBeNullOrEmpty();
attachment.Size.Should().BeGreaterThan(0);
}
}
[Fact]
public async Task Check_Stream_PdfWithSwissQrCode_HasNoAttachments()
{
// QR code PDF has no embedded file attachments
using var stream = LoadPdfStream("pdfWithSwissQRCode.pdf");
var result = await AttachmentClient.CheckAsync(stream);
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
}
[Fact]
public async Task Check_Stream_EmptyStream_Throws()
{
using var stream = new MemoryStream(Array.Empty<byte>());
var act = async () => await AttachmentClient.CheckAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task Check_Stream_CorruptedContent_Throws()
{
byte[] garbage = "This is not a valid PDF content"u8.ToArray();
using var stream = new MemoryStream(garbage);
var act = async () => await AttachmentClient.CheckAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>(
"corrupted PDF should return 500 → client throws");
}
// ──────────────────────────────────────────────────────────────
// CheckAsync(byte[]) → Base64 JSON → /api/pdf/attachments/check
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Check_Bytes_PdfWithoutAttachments_ReturnsHasAttachmentsFalse()
{
byte[] bytes = LoadPdfBytes("valid.pdf");
var result = await AttachmentClient.CheckAsync(bytes);
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
}
[Fact]
public async Task Check_Bytes_PdfWith6Attachments_ReturnsAllAttachmentsWithMetadata()
{
byte[] bytes = LoadPdfBytes("pdfWithMoreThanOneAttachment.pdf");
var result = await AttachmentClient.CheckAsync(bytes);
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(6);
result.Attachments.Should().HaveCount(6);
foreach (var attachment in result.Attachments)
{
attachment.FileName.Should().NotBeNullOrEmpty();
attachment.MimeType.Should().NotBeNullOrEmpty();
attachment.Size.Should().BeGreaterThan(0);
}
}
[Fact]
public async Task Check_Bytes_EmptyArray_Throws()
{
var act = async () => await AttachmentClient.CheckAsync(Array.Empty<byte>());
await act.Should().ThrowAsync<HttpRequestException>();
}
// ──────────────────────────────────────────────────────────────
// ExtractAsync(Stream) → multipart → /api/pdf/attachments/extract
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Extract_Stream_PdfWith6Attachments_Returns6NamedStreams()
{
using var stream = LoadPdfStream("pdfWithMoreThanOneAttachment.pdf");
var attachments = await AttachmentClient.ExtractAsync(stream);
attachments.Should().HaveCount(6, "PDF contains 6 attachments");
foreach (var (name, content) in attachments)
{
name.Should().NotBeNullOrEmpty();
content.Length.Should().BeGreaterThan(0);
content.Dispose();
}
}
[Fact]
public async Task Extract_Stream_AttachmentNamesAreUnique()
{
using var stream = LoadPdfStream("pdfWithMoreThanOneAttachment.pdf");
var attachments = await AttachmentClient.ExtractAsync(stream);
try
{
attachments.Keys.Should().OnlyHaveUniqueItems(
"ZIP entry names must be unique within the archive");
}
finally
{
foreach (var s in attachments.Values) s.Dispose();
}
}
[Fact]
public async Task Extract_Stream_EmptyStream_Throws()
{
using var stream = new MemoryStream(Array.Empty<byte>());
var act = async () => await AttachmentClient.ExtractAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task Extract_Stream_CorruptedContent_Throws()
{
byte[] garbage = "This is not a valid PDF content"u8.ToArray();
using var stream = new MemoryStream(garbage);
var act = async () => await AttachmentClient.ExtractAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>();
}
// ──────────────────────────────────────────────────────────────
// ExtractAsync(byte[]) → Base64 JSON → /api/pdf/attachments/extract
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Extract_Bytes_PdfWith6Attachments_Returns6NamedStreams()
{
byte[] bytes = LoadPdfBytes("pdfWithMoreThanOneAttachment.pdf");
var attachments = await AttachmentClient.ExtractAsync(bytes);
attachments.Should().HaveCount(6);
foreach (var (name, content) in attachments)
{
name.Should().NotBeNullOrEmpty();
content.Length.Should().BeGreaterThan(0);
content.Dispose();
}
}
[Fact]
public async Task Extract_Bytes_AttachmentContentsAreNonEmpty()
{
byte[] bytes = LoadPdfBytes("pdfWithMoreThanOneAttachment.pdf");
var attachments = await AttachmentClient.ExtractAsync(bytes);
try
{
foreach (var (_, content) in attachments)
{
((MemoryStream)content).ToArray().Should().NotBeEmpty(
"every extracted attachment must have content");
}
}
finally
{
foreach (var s in attachments.Values) s.Dispose();
}
}
[Fact]
public async Task Extract_Bytes_EmptyArray_Throws()
{
var act = async () => await AttachmentClient.ExtractAsync(Array.Empty<byte>());
await act.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,532 @@
using DocumentService.Application.Common.DTOs.Requests;
using DocumentService.Domain.Models.ValueObjects;
using FluentAssertions;
namespace DocumentService.Tests.Live.API;
/// <summary>
/// Live tests for PDF operations: merge, annotate, stamp.
/// Uses PdfOperationsClient — routes managed by the client.
/// Target: http://172.24.12.39:9393/
/// </summary>
[Collection("Live API")]
public class PdfOperationsLiveTests : LiveApiTestBase
{
// ══════════════════════════════════════════════════════════════
// MERGE — MergeAsync(IEnumerable<Stream>) → multipart
// ══════════════════════════════════════════════════════════════
[Fact]
public async Task Merge_Streams_TwoPdfs_ReturnsMergedPdfStream()
{
using var pdf1 = LoadPdfStream("valid.pdf");
using var pdf2 = LoadPdfStream("valid.pdf");
await using var merged = await OperationsClient.MergeAsync(new[] { pdf1, pdf2 });
merged.Should().NotBeNull();
merged.Length.Should().BeGreaterThan(0);
// Merged must start with PDF magic bytes (%PDF = 0x25 0x50 0x44 0x46)
var header = new byte[4];
merged.ReadExactly(header, 0, 4);
header.Should().Equal(new byte[] { 0x25, 0x50, 0x44, 0x46 },
"merged result must be a valid PDF");
}
[Fact]
public async Task Merge_Streams_ThreePdfs_ReturnsMergedPdfStream()
{
using var pdf1 = LoadPdfStream("valid.pdf");
using var pdf2 = LoadPdfStream("valid.pdf");
using var pdf3 = LoadPdfStream("valid.pdf");
await using var merged = await OperationsClient.MergeAsync(new[] { pdf1, pdf2, pdf3 });
merged.Should().NotBeNull();
merged.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Merge_Streams_OnlyOnePdf_Throws()
{
using var pdf = LoadPdfStream("valid.pdf");
var act = async () =>
{
await using var result = await OperationsClient.MergeAsync(new[] { pdf });
};
await act.Should().ThrowAsync<HttpRequestException>(
"at least 2 PDFs are required; single PDF should return 400");
}
[Fact]
public async Task Merge_Streams_CorruptedPdf_Throws()
{
byte[] garbage = "NOT A PDF"u8.ToArray();
using var corrupted = new MemoryStream(garbage);
using var valid = LoadPdfStream("valid.pdf");
var act = async () =>
{
await using var result = await OperationsClient.MergeAsync(new[] { corrupted, valid });
};
await act.Should().ThrowAsync<HttpRequestException>(
"corrupted PDF should return 500");
}
// ══════════════════════════════════════════════════════════════
// MERGE — MergeAsync(IEnumerable<byte[]>) → Base64 JSON
// ══════════════════════════════════════════════════════════════
[Fact]
public async Task Merge_Bytes_TwoPdfs_ReturnsMergedPdfStream()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
await using var merged = await OperationsClient.MergeAsync(new[] { pdf, pdf });
merged.Should().NotBeNull();
merged.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Merge_Bytes_WithPageRanges_ReturnsMergedPdfStream()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
await using var merged = await OperationsClient.MergeAsync(
new[] { pdf, pdf },
pageRanges: ["1", "1"]);
merged.Should().NotBeNull();
merged.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Merge_Bytes_OnlyOnePdf_Throws()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var act = async () =>
{
await using var result = await OperationsClient.MergeAsync(new[] { pdf });
};
await act.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task Merge_Bytes_PageRangeExceedsPageCount_Throws()
{
byte[] pdf = LoadPdfBytes("valid.pdf"); // 1-page PDF
var act = async () =>
{
await using var result = await OperationsClient.MergeAsync(
new[] { pdf, pdf },
pageRanges: ["999-1000", null]);
};
await act.Should().ThrowAsync<HttpRequestException>(
"out-of-bounds page range should return 400");
}
// ══════════════════════════════════════════════════════════════
// ANNOTATE — AnnotateAsync(Stream, ...) → multipart
// ══════════════════════════════════════════════════════════════
[Fact]
public async Task Annotate_Stream_TextMarkupHighlight_ReturnsAnnotatedPdf()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty, // not used in Stream overload
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 100, Y1 = 100, X2 = 300, Y2 = 120,
TextMarkupStyle = TextMarkupStyle.Highlight,
Color = "FFFF00",
Author = "LiveTest"
};
await using var result = await OperationsClient.AnnotateAsync(stream, request);
result.Should().NotBeNull();
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Annotate_Stream_Underline_ReturnsAnnotatedPdf()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 50, Y1 = 700, X2 = 300, Y2 = 720,
TextMarkupStyle = TextMarkupStyle.Underline
};
await using var result = await OperationsClient.AnnotateAsync(stream, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Annotate_Stream_Circle_ReturnsAnnotatedPdf()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
X1 = 100, Y1 = 200, X2 = 200, Y2 = 300,
Color = "00FF00",
Content = "Circle annotation"
};
await using var result = await OperationsClient.AnnotateAsync(stream, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Annotate_Stream_Square_ReturnsAnnotatedPdf()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.Square,
PageNumber = 1,
X1 = 250, Y1 = 250, X2 = 350, Y2 = 350,
Color = "0000FF"
};
await using var result = await OperationsClient.AnnotateAsync(stream, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Annotate_Stream_InvalidPageNumber_Throws()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.FreeText,
PageNumber = 9999, // exceeds page count
X1 = 0, Y1 = 0, X2 = 100, Y2 = 100,
Content = "Test"
};
var act = async () =>
{
await using var result = await OperationsClient.AnnotateAsync(stream, request);
};
await act.Should().ThrowAsync<HttpRequestException>(
"invalid page number should return 400");
}
// ══════════════════════════════════════════════════════════════
// ANNOTATE — AnnotateAsync(byte[], ...) → Base64 JSON
// ══════════════════════════════════════════════════════════════
[Fact]
public async Task Annotate_Bytes_FreeText_ReturnsAnnotatedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty, // client fills this in
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 50, Y1 = 50, X2 = 200, Y2 = 100,
Content = "Free text from live test",
Author = "LiveTestRunner",
Color = "FF0000"
};
await using var result = await OperationsClient.AnnotateAsync(pdf, request);
result.Should().NotBeNull();
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Annotate_Bytes_StickyNote_ReturnsAnnotatedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.StickyNote,
PageNumber = 1,
X1 = 300, Y1 = 300, X2 = 320, Y2 = 320,
Content = "Sticky note from live test",
Author = "Reviewer"
};
await using var result = await OperationsClient.AnnotateAsync(pdf, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Annotate_Bytes_TextMarkupStrikeout_ReturnsAnnotatedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 60, Y1 = 600, X2 = 250, Y2 = 620,
TextMarkupStyle = TextMarkupStyle.Strikeout,
Color = "FF6600"
};
await using var result = await OperationsClient.AnnotateAsync(pdf, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Annotate_Bytes_FreeTextWithoutContent_Throws()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 0, Y1 = 0, X2 = 100, Y2 = 100
// Content intentionally missing
};
var act = async () =>
{
await using var result = await OperationsClient.AnnotateAsync(pdf, request);
};
await act.Should().ThrowAsync<HttpRequestException>(
"FreeText without content should return 400");
}
[Fact]
public async Task Annotate_Bytes_TextMarkupWithoutStyle_Throws()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddAnnotationBase64Request
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 0, Y1 = 0, X2 = 100, Y2 = 100
// TextMarkupStyle intentionally missing
};
var act = async () =>
{
await using var result = await OperationsClient.AnnotateAsync(pdf, request);
};
await act.Should().ThrowAsync<HttpRequestException>(
"TextMarkup without style should return 400");
}
// ══════════════════════════════════════════════════════════════
// STAMP — StampAsync(Stream, ...) → multipart
// ══════════════════════════════════════════════════════════════
[Fact]
public async Task Stamp_Stream_TextStamp_ReturnsStampedPdf()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
X = 100, Y = 400,
Text = "CONFIDENTIAL",
FontSize = 24,
Color = "FF0000",
Opacity = 0.5
};
await using var result = await OperationsClient.StampAsync(stream, request);
result.Should().NotBeNull();
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Stamp_Stream_TextStamp_WithRotation_ReturnsStampedPdf()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
X = 300, Y = 400,
Text = "DRAFT",
Rotation = 45,
Opacity = 0.3,
Placement = StampPlacement.Background
};
await using var result = await OperationsClient.StampAsync(stream, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Stamp_Stream_PredefinedStamp_Confidential_ReturnsStampedPdf()
{
using var stream = LoadPdfStream("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Predefined,
X = 100, Y = 400,
PredefinedType = PredefinedStampType.Confidential,
Opacity = 0.6
};
await using var result = await OperationsClient.StampAsync(stream, request);
result.Length.Should().BeGreaterThan(0);
}
// ══════════════════════════════════════════════════════════════
// STAMP — StampAsync(byte[], ...) → Base64 JSON
// ══════════════════════════════════════════════════════════════
[Fact]
public async Task Stamp_Bytes_TextStamp_ReturnsStampedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
X = 100, Y = 400,
Text = "APPROVED",
FontName = "Arial",
FontSize = 20,
Color = "008000",
Opacity = 0.7,
Placement = StampPlacement.Foreground
};
await using var result = await OperationsClient.StampAsync(pdf, request);
result.Should().NotBeNull();
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Stamp_Bytes_WatermarkBackground_ReturnsStampedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
X = 150, Y = 350,
Text = "WATERMARK",
FontSize = 36,
Color = "AAAAAA",
Opacity = 0.2,
Rotation = 45,
Placement = StampPlacement.Background
};
await using var result = await OperationsClient.StampAsync(pdf, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Stamp_Bytes_PredefinedStamp_Approved_ReturnsStampedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Predefined,
X = 100, Y = 400,
PredefinedType = PredefinedStampType.Approved,
Opacity = 0.8
};
await using var result = await OperationsClient.StampAsync(pdf, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Stamp_Bytes_PredefinedStamp_Draft_ReturnsStampedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Predefined,
X = 200, Y = 500,
PredefinedType = PredefinedStampType.Draft,
Opacity = 0.5
};
await using var result = await OperationsClient.StampAsync(pdf, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Stamp_Bytes_TextStampOnSpecificPage_ReturnsStampedPdf()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
PageNumbers = [1],
X = 200, Y = 500,
Text = "PAGE 1 ONLY",
Opacity = 0.5
};
await using var result = await OperationsClient.StampAsync(pdf, request);
result.Length.Should().BeGreaterThan(0);
}
[Fact]
public async Task Stamp_Bytes_TextStampWithoutText_Throws()
{
byte[] pdf = LoadPdfBytes("valid.pdf");
var request = new AddStampBase64Request
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
X = 100, Y = 400
// Text intentionally missing
};
var act = async () =>
{
await using var result = await OperationsClient.StampAsync(pdf, request);
};
await act.Should().ThrowAsync<HttpRequestException>(
"Text stamp without text should return 400");
}
}

View File

@@ -0,0 +1,196 @@
using DocumentService.Application.Common.DTOs;
using FluentAssertions;
namespace DocumentService.Tests.Live.API;
/// <summary>
/// Live tests for PDF validation endpoints.
/// Uses PdfValidationClient — routes are managed by the client, not the test.
/// Target: http://172.24.12.39:9393/
/// </summary>
[Collection("Live API")]
public class PdfValidationLiveTests : LiveApiTestBase
{
// ──────────────────────────────────────────────────────────────
// ValidateAsync(Stream) → multipart/form-data → /api/pdf/validation/validate
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Validate_Stream_ValidPdf_ReturnsMetadata()
{
using var stream = LoadPdfStream("valid.pdf");
var result = await ValidationClient.ValidateAsync(stream);
result.Should().NotBeNull();
result.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
}
[Fact]
public async Task Validate_Stream_PdfWithAttachments_ReturnsHasAttachmentsTrue()
{
using var stream = LoadPdfStream("pdfWithMoreThanOneAttachment.pdf");
var result = await ValidationClient.ValidateAsync(stream);
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task Validate_Stream_PdfWithSwissQrCode_ReturnsValidResult()
{
using var stream = LoadPdfStream("pdfWithSwissQRCode.pdf");
var result = await ValidationClient.ValidateAsync(stream);
result.Should().NotBeNull();
result.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
}
[Fact]
public async Task Validate_Stream_EmptyStream_Throws()
{
using var stream = new MemoryStream(Array.Empty<byte>());
var act = async () => await ValidationClient.ValidateAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>(
"empty file should be rejected with 400");
}
[Fact]
public async Task Validate_Stream_CorruptedContent_Throws()
{
// Valid stream shape, but not a PDF
byte[] garbage = "THIS IS NOT A PDF FILE"u8.ToArray();
using var stream = new MemoryStream(garbage);
var act = async () => await ValidationClient.ValidateAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>(
"corrupted content should be rejected (500)");
}
// ──────────────────────────────────────────────────────────────
// ValidateAsync(byte[]) → Base64 JSON → /api/pdf/validation/validate
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Validate_Bytes_ValidPdf_ReturnsMetadata()
{
byte[] bytes = LoadPdfBytes("valid.pdf");
var result = await ValidationClient.ValidateAsync(bytes);
result.Should().NotBeNull();
result.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Validate_Bytes_PdfWithAttachments_ReturnsHasAttachmentsTrue()
{
byte[] bytes = LoadPdfBytes("pdfWithMoreThanOneAttachment.pdf");
var result = await ValidationClient.ValidateAsync(bytes);
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task Validate_Bytes_EmptyArray_Throws()
{
var act = async () => await ValidationClient.ValidateAsync(Array.Empty<byte>());
await act.Should().ThrowAsync<HttpRequestException>(
"empty byte array encodes to empty Base64, which should be rejected");
}
// ──────────────────────────────────────────────────────────────
// ValidatePdfAAsync(Stream) → multipart → /api/pdf/validation/validate-pdfa
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task ValidatePdfA_Stream_ValidPdf_ReturnsComplianceFields()
{
using var stream = LoadPdfStream("valid.pdf");
var result = await ValidationClient.ValidatePdfAAsync(stream);
result.Should().NotBeNull();
result.IsValid.Should().BeTrue();
result.PageCount.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
result.FileSize.Should().BeGreaterThan(0);
result.Encrypted.Should().BeFalse();
// valid.pdf is a regular PDF, not PDF/A
result.PdfACompliant.Should().BeFalse();
result.PdfAVersion.Should().BeNullOrEmpty();
}
[Fact]
public async Task ValidatePdfA_Stream_EncryptedPdf_ReturnsEncryptedTrue_NotCompliant()
{
using var stream = LoadPdfStream("encryptedPdf.pdf");
var result = await ValidationClient.ValidatePdfAAsync(stream);
result.Should().NotBeNull();
result.Encrypted.Should().BeTrue();
result.PdfACompliant.Should().BeFalse("encrypted PDFs cannot be PDF/A compliant");
}
[Fact]
public async Task ValidatePdfA_Stream_EmptyStream_Throws()
{
using var stream = new MemoryStream(Array.Empty<byte>());
var act = async () => await ValidationClient.ValidatePdfAAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>();
}
// ──────────────────────────────────────────────────────────────
// ValidatePdfAAsync(byte[]) → Base64 JSON → /api/pdf/validation/validate-pdfa
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task ValidatePdfA_Bytes_ValidPdf_ReturnsComplianceFields()
{
byte[] bytes = LoadPdfBytes("valid.pdf");
var result = await ValidationClient.ValidatePdfAAsync(bytes);
result.Should().NotBeNull();
result.IsValid.Should().BeTrue();
result.PdfACompliant.Should().BeFalse();
result.PageCount.Should().BeGreaterThan(0);
}
[Fact]
public async Task ValidatePdfA_Bytes_EncryptedPdf_ReturnsEncryptedTrue()
{
byte[] bytes = LoadPdfBytes("encryptedPdf.pdf");
var result = await ValidationClient.ValidatePdfAAsync(bytes);
result.Encrypted.Should().BeTrue();
result.PdfACompliant.Should().BeFalse();
}
[Fact]
public async Task ValidatePdfA_Bytes_EmptyArray_Throws()
{
var act = async () => await ValidationClient.ValidatePdfAAsync(Array.Empty<byte>());
await act.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,129 @@
using DocumentService.Application.Common.DTOs;
using FluentAssertions;
namespace DocumentService.Tests.Live.API;
/// <summary>
/// Live tests for Swiss QR Code extraction endpoint.
/// Uses SwissQrCodeClient — routes managed by the client.
/// Target: http://172.24.12.39:9393/
/// </summary>
[Collection("Live API")]
public class SwissQrCodeLiveTests : LiveApiTestBase
{
// ──────────────────────────────────────────────────────────────
// ExtractAsync(Stream) → multipart → /api/pdf/qr-code/extract-swiss
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Extract_Stream_PdfWithQrCode_ReturnsBillDetails()
{
using var stream = LoadPdfStream("pdfWithSwissQRCode.pdf");
var result = await QrCodeClient.ExtractAsync(stream);
result.Should().NotBeNull();
result.Bill.Should().NotBeNull("bill data must be extracted");
result.RawLines.Should().NotBeEmpty("raw QR payload lines must be present");
// Swiss QR Bill Standard 2.0 payload always starts with "SPC"
result.RawLines.Should().Contain(line => line == "SPC",
"first SPC line is mandated by Swiss QR Bill Standard 2.0");
}
[Fact]
public async Task Extract_Stream_PdfWithQrCode_BillHasMandatoryFields()
{
using var stream = LoadPdfStream("pdfWithSwissQRCode.pdf");
var result = await QrCodeClient.ExtractAsync(stream);
var bill = result.Bill;
bill.Should().NotBeNull();
bill!.Account.Should().NotBeNullOrEmpty("IBAN/QR-IBAN is mandatory per standard");
bill.Creditor.Should().NotBeNull("Creditor is mandatory");
bill.Creditor!.Name.Should().NotBeNullOrEmpty("Creditor name is mandatory");
}
[Fact]
public async Task Extract_Stream_PdfWithoutQrCode_Throws404()
{
// valid.pdf has no QR code
using var stream = LoadPdfStream("valid.pdf");
var act = async () => await QrCodeClient.ExtractAsync(stream);
// Client calls EnsureSuccessStatusCode → HttpRequestException on 404
await act.Should().ThrowAsync<HttpRequestException>(
"PDF without QR code must return 404");
}
[Fact]
public async Task Extract_Stream_EmptyStream_Throws()
{
using var stream = new MemoryStream(Array.Empty<byte>());
var act = async () => await QrCodeClient.ExtractAsync(stream);
await act.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task Extract_Stream_WithRawFlag_ReturnsRawLines()
{
using var stream = LoadPdfStream("pdfWithSwissQRCode.pdf");
// raw=true → API returns full unprocessed payload lines
var result = await QrCodeClient.ExtractAsync(stream, raw: true);
result.Should().NotBeNull();
result.RawLines.Should().NotBeEmpty();
}
// ──────────────────────────────────────────────────────────────
// ExtractAsync(byte[]) → Base64 JSON → /api/pdf/qr-code/extract-swiss
// ──────────────────────────────────────────────────────────────
[Fact]
public async Task Extract_Bytes_PdfWithQrCode_ReturnsBillDetails()
{
byte[] bytes = LoadPdfBytes("pdfWithSwissQRCode.pdf");
var result = await QrCodeClient.ExtractAsync(bytes);
result.Should().NotBeNull();
result.Bill.Should().NotBeNull();
result.RawLines.Should().NotBeEmpty();
}
[Fact]
public async Task Extract_Bytes_PdfWithQrCode_BillHasMandatoryFields()
{
byte[] bytes = LoadPdfBytes("pdfWithSwissQRCode.pdf");
var result = await QrCodeClient.ExtractAsync(bytes);
result.Bill!.Account.Should().NotBeNullOrEmpty();
result.Bill.Creditor.Should().NotBeNull();
result.Bill.Creditor!.Name.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task Extract_Bytes_PdfWithoutQrCode_Throws404()
{
byte[] bytes = LoadPdfBytes("valid.pdf");
var act = async () => await QrCodeClient.ExtractAsync(bytes);
await act.Should().ThrowAsync<HttpRequestException>(
"PDF without QR code must return 404");
}
[Fact]
public async Task Extract_Bytes_EmptyArray_Throws()
{
var act = async () => await QrCodeClient.ExtractAsync(Array.Empty<byte>());
await act.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<None Remove="TestData\Pdfs\pdfWithSwissQRCode.pdf" />
<None Remove="TestData\Pdfs\valid.pdf" />
<None Remove="TestData\Pdfs\pdfWithMoreThanOneAttachment.pdf" />
<None Remove="TestData\Pdfs\encryptedPdf.pdf" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="TestData\Pdfs\pdfWithSwissQRCode.pdf" />
<EmbeddedResource Include="TestData\Pdfs\valid.pdf" />
<EmbeddedResource Include="TestData\Pdfs\pdfWithMoreThanOneAttachment.pdf" />
<EmbeddedResource Include="TestData\Pdfs\encryptedPdf.pdf" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
<ProjectReference Include="..\DocumentService.Application\DocumentService.Application.csproj" />
<ProjectReference Include="..\DocumentService.Domain\DocumentService.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,64 @@
using DocumentService.Client.Clients;
using Microsoft.Extensions.Logging.Abstractions;
using System.Reflection;
namespace DocumentService.Tests.Live;
/// <summary>
/// Shared base for all live API tests.
/// Uses the typed DocumentService.Client classes — no hardcoded routes.
///
/// Live API: http://172.24.12.39:9393/
/// Override via LIVE_API_BASE_URL environment variable if needed.
/// </summary>
public abstract class LiveApiTestBase : IDisposable
{
public static readonly string BaseUrl =
Environment.GetEnvironmentVariable("LIVE_API_BASE_URL")
?? "http://172.24.12.39:9393";
protected readonly HttpClient HttpClient;
// Typed clients (route-free — routes live inside the client implementations)
protected readonly PdfValidationClient ValidationClient;
protected readonly PdfAttachmentClient AttachmentClient;
protected readonly PdfOperationsClient OperationsClient;
protected readonly SwissQrCodeClient QrCodeClient;
protected LiveApiTestBase()
{
HttpClient = new HttpClient { BaseAddress = new Uri(BaseUrl) };
ValidationClient = new PdfValidationClient(HttpClient, NullLogger<PdfValidationClient>.Instance);
AttachmentClient = new PdfAttachmentClient(HttpClient, NullLogger<PdfAttachmentClient>.Instance);
OperationsClient = new PdfOperationsClient(HttpClient, NullLogger<PdfOperationsClient>.Instance);
QrCodeClient = new SwissQrCodeClient(HttpClient, NullLogger<SwissQrCodeClient>.Instance);
}
// ──────────────────────────────────────────────────────────────
// Embedded-resource helpers
// ──────────────────────────────────────────────────────────────
protected static byte[] LoadPdfBytes(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentService.Tests.Live.TestData.Pdfs.{fileName}";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new FileNotFoundException(
$"Embedded resource not found: '{resourceName}'. " +
$"Available: {string.Join(", ", assembly.GetManifestResourceNames())}");
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
protected static Stream LoadPdfStream(string fileName)
{
var bytes = LoadPdfBytes(fileName);
return new MemoryStream(bytes);
}
public void Dispose() => HttpClient.Dispose();
}

View File

@@ -0,0 +1,30 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
4 0 obj
<< /Filter /Standard /V 2 /R 3 /Length 128 /P -3904 /O (OwnerHash12345678901234567890123456) /U (UserHash12345678901234567890123456) >>
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000206 00000 n
trailer
<< /Size 5 /Root 1 0 R /Encrypt 4 0 R >>
startxref
370
%%EOF

Binary file not shown.

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="valid.pdf" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DocumentService.Client" Version="1.2.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
using DocumentService.Client;
// 1. Configure the client
Client.Configure("http://172.24.12.39:9393/");
// 2. Load embedded PDF resource
var assembly = typeof(Program).Assembly;
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("valid.pdf", StringComparison.OrdinalIgnoreCase));
if (resourceName is null)
{
Console.WriteLine("❌ Embedded resource 'valid.pdf' not found.");
return;
}
try
{
using var stream = assembly.GetManifestResourceStream(resourceName)!;
var result = await Client.Validation.ValidateAsync(stream);
Console.WriteLine("✅ Connection successful!");
Console.WriteLine($" Page count : {result.PageCount}");
Console.WriteLine($" PDF version : {result.PdfVersion}");
Console.WriteLine($" Encrypted : {result.IsEncrypted}");
Console.WriteLine($" Attachments : {result.AttachmentCount}");
Console.WriteLine($" File size : {result.FileSizeMB:F2} MB");
}
catch (HttpRequestException ex)
{
Console.WriteLine("❌ HTTP error: " + ex.Message);
}
catch (InvalidOperationException ex)
{
Console.WriteLine("❌ Client error: " + ex.Message);
}

Binary file not shown.

View File

@@ -54,10 +54,10 @@ public class PdfAttachmentClientTests
return ms.ToArray(); return ms.ToArray();
} }
// ?? CheckAttachmentsAsync (Stream) ??????????????????????????????????????? // ?? CheckAsync (Stream) ???????????????????????????????????????
[Fact] [Fact]
public async Task CheckAttachmentsAsync_Stream_SendsMultipartPost() public async Task CheckAsync_Stream_SendsMultipartPost()
{ {
// Arrange // Arrange
var expected = new AttachmentCheckResult var expected = new AttachmentCheckResult
@@ -72,7 +72,7 @@ public class PdfAttachmentClientTests
var (client, handler) = BuildJson(expected); var (client, handler) = BuildJson(expected);
// Act // Act
var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes())); var result = await client.CheckAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.HasAttachments.Should().BeTrue(); result.HasAttachments.Should().BeTrue();
@@ -83,14 +83,14 @@ public class PdfAttachmentClientTests
} }
[Fact] [Fact]
public async Task CheckAttachmentsAsync_Stream_PdfWithNoAttachments_ReturnsFalse() public async Task CheckAsync_Stream_PdfWithNoAttachments_ReturnsFalse()
{ {
// Arrange // Arrange
var expected = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0, Attachments = [] }; var expected = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0, Attachments = [] };
var (client, _) = BuildJson(expected); var (client, _) = BuildJson(expected);
// Act // Act
var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes())); var result = await client.CheckAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.HasAttachments.Should().BeFalse(); result.HasAttachments.Should().BeFalse();
@@ -98,17 +98,17 @@ public class PdfAttachmentClientTests
result.Attachments.Should().BeEmpty(); result.Attachments.Should().BeEmpty();
} }
// ?? CheckAttachmentsAsync (byte[]) ??????????????????????????????????????? // ?? CheckAsync (byte[]) ???????????????????????????????????????
[Fact] [Fact]
public async Task CheckAttachmentsAsync_Bytes_SendsJsonWithBase64() public async Task CheckAsync_Bytes_SendsJsonWithBase64()
{ {
// Arrange // Arrange
var expected = new AttachmentCheckResult { HasAttachments = false }; var expected = new AttachmentCheckResult { HasAttachments = false };
var (client, handler) = BuildJson(expected); var (client, handler) = BuildJson(expected);
// Act // Act
await client.CheckAttachmentsAsync(FakePdfBytes()); await client.CheckAsync(FakePdfBytes());
// Assert // Assert
handler.LastRequest!.Content.Should().NotBeNull(); handler.LastRequest!.Content.Should().NotBeNull();
@@ -118,10 +118,10 @@ public class PdfAttachmentClientTests
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty(); doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
} }
// ?? ExtractAttachmentsAsync (Stream) <20> ZIP unzip ????????????????????????? // ?? ExtractAsync (Stream) <20> ZIP unzip ?????????????????????????
[Fact] [Fact]
public async Task ExtractAttachmentsAsync_Stream_UnzipsAndReturnsDictionary() public async Task ExtractAsync_Stream_UnzipsAndReturnsDictionary()
{ {
// Arrange // Arrange
var zipBytes = BuildZip(new Dictionary<string, string> var zipBytes = BuildZip(new Dictionary<string, string>
@@ -132,7 +132,7 @@ public class PdfAttachmentClientTests
var (client, handler) = BuildBytes(zipBytes); var (client, handler) = BuildBytes(zipBytes);
// Act // Act
var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes())); var result = await client.ExtractAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.Should().HaveCount(2); result.Should().HaveCount(2);
@@ -150,7 +150,7 @@ public class PdfAttachmentClientTests
} }
[Fact] [Fact]
public async Task ExtractAttachmentsAsync_Stream_WithSingleEntry_ReturnsOneItem() public async Task ExtractAsync_Stream_WithSingleEntry_ReturnsOneItem()
{ {
// Arrange // Arrange
var zipBytes = BuildZip(new Dictionary<string, string> var zipBytes = BuildZip(new Dictionary<string, string>
@@ -160,7 +160,7 @@ public class PdfAttachmentClientTests
var (client, _) = BuildBytes(zipBytes); var (client, _) = BuildBytes(zipBytes);
// Act // Act
var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes())); var result = await client.ExtractAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.Should().HaveCount(1); result.Should().HaveCount(1);
@@ -169,10 +169,10 @@ public class PdfAttachmentClientTests
foreach (var s in result.Values) s.Dispose(); foreach (var s in result.Values) s.Dispose();
} }
// ?? ExtractAttachmentsAsync (byte[]) <20> ZIP unzip ????????????????????????? // ?? ExtractAsync (byte[]) <20> ZIP unzip ?????????????????????????
[Fact] [Fact]
public async Task ExtractAttachmentsAsync_Bytes_SendsJsonAndUnzips() public async Task ExtractAsync_Bytes_SendsJsonAndUnzips()
{ {
// Arrange // Arrange
var zipBytes = BuildZip(new Dictionary<string, string> var zipBytes = BuildZip(new Dictionary<string, string>
@@ -182,7 +182,7 @@ public class PdfAttachmentClientTests
var (client, handler) = BuildBytes(zipBytes); var (client, handler) = BuildBytes(zipBytes);
// Act // Act
var result = await client.ExtractAttachmentsAsync(FakePdfBytes()); var result = await client.ExtractAsync(FakePdfBytes());
// Assert // Assert
result.Should().ContainKey("invoice.xml"); result.Should().ContainKey("invoice.xml");
@@ -195,7 +195,7 @@ public class PdfAttachmentClientTests
// ?? HTTP error propagation ??????????????????????????????????????????????? // ?? HTTP error propagation ???????????????????????????????????????????????
[Fact] [Fact]
public async Task CheckAttachmentsAsync_WhenApiReturns404_ThrowsHttpRequestException() public async Task CheckAsync_WhenApiReturns404_ThrowsHttpRequestException()
{ {
// Arrange // Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound); var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
@@ -203,12 +203,12 @@ public class PdfAttachmentClientTests
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance); var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
// Act & Assert // Act & Assert
await client.Invoking(c => c.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes()))) await client.Invoking(c => c.CheckAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }
[Fact] [Fact]
public async Task ExtractAttachmentsAsync_WhenApiReturns500_ThrowsHttpRequestException() public async Task ExtractAsync_WhenApiReturns500_ThrowsHttpRequestException()
{ {
// Arrange // Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError); var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
@@ -216,7 +216,7 @@ public class PdfAttachmentClientTests
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance); var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
// Act & Assert // Act & Assert
await client.Invoking(c => c.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes()))) await client.Invoking(c => c.ExtractAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }
} }

View File

@@ -28,15 +28,15 @@ public class PdfValidationClientTests
private static byte[] FakePdfBytes() => "fake-pdf"u8.ToArray(); private static byte[] FakePdfBytes() => "fake-pdf"u8.ToArray();
// ── ValidatePdfAsync (Stream) ──────────────────────────────────────────── // ── ValidateAsync (Stream) ────────────────────────────────────────────
[Fact] [Fact]
public async Task ValidatePdfAsync_Stream_SendsMultipartPost() public async Task ValidateAsync_Stream_SendsMultipartPost()
{ {
var expected = new PdfValidationResult(PageCount: 3, FileSizeBytes: 2048, FileSizeMB: 0, PdfVersion: "1.7", HasAttachments: false, AttachmentCount: 0, IsEncrypted: false); var expected = new PdfValidationResult(PageCount: 3, FileSizeBytes: 2048, FileSizeMB: 0, PdfVersion: "1.7", HasAttachments: false, AttachmentCount: 0, IsEncrypted: false);
var (client, handler) = Build(expected); var (client, handler) = Build(expected);
var result = await client.ValidatePdfAsync(new MemoryStream(FakePdfBytes())); var result = await client.ValidateAsync(new MemoryStream(FakePdfBytes()));
result.PageCount.Should().Be(3); result.PageCount.Should().Be(3);
result.PdfVersion.Should().Be("1.7"); result.PdfVersion.Should().Be("1.7");
@@ -48,52 +48,52 @@ public class PdfValidationClientTests
} }
[Fact] [Fact]
public async Task ValidatePdfAsync_Stream_EncryptedPdf_DeserializesIsEncryptedTrue() public async Task ValidateAsync_Stream_EncryptedPdf_DeserializesIsEncryptedTrue()
{ {
var expected = new PdfValidationResult(PageCount: 1, FileSizeBytes: 0, FileSizeMB: 0, PdfVersion: "1.6", HasAttachments: false, AttachmentCount: 0, IsEncrypted: true); var expected = new PdfValidationResult(PageCount: 1, FileSizeBytes: 0, FileSizeMB: 0, PdfVersion: "1.6", HasAttachments: false, AttachmentCount: 0, IsEncrypted: true);
var (client, _) = Build(expected); var (client, _) = Build(expected);
var result = await client.ValidatePdfAsync(new MemoryStream(FakePdfBytes())); var result = await client.ValidateAsync(new MemoryStream(FakePdfBytes()));
result.IsEncrypted.Should().BeTrue("encrypted PDFs should surface IsEncrypted=true"); result.IsEncrypted.Should().BeTrue("encrypted PDFs should surface IsEncrypted=true");
} }
[Fact] [Fact]
public async Task ValidatePdfAsync_Stream_ThrowsWhenApiReturnsNull() public async Task ValidateAsync_Stream_ThrowsWhenApiReturnsNull()
{ {
var handler = MockHttpMessageHandler.ReturningJson<PdfValidationResult?>(null); var handler = MockHttpMessageHandler.ReturningJson<PdfValidationResult?>(null);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance); var client = new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance);
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes()))) await client.Invoking(c => c.ValidateAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<InvalidOperationException>(); .Should().ThrowAsync<InvalidOperationException>();
} }
[Fact] [Fact]
public async Task ValidatePdfAsync_Stream_WhenApiReturns400_ThrowsHttpRequestException() public async Task ValidateAsync_Stream_WhenApiReturns400_ThrowsHttpRequestException()
{ {
var (client, _) = BuildError(HttpStatusCode.BadRequest); var (client, _) = BuildError(HttpStatusCode.BadRequest);
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes()))) await client.Invoking(c => c.ValidateAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }
[Fact] [Fact]
public async Task ValidatePdfAsync_Stream_WhenApiReturns500_ThrowsHttpRequestException() public async Task ValidateAsync_Stream_WhenApiReturns500_ThrowsHttpRequestException()
{ {
var (client, _) = BuildError(HttpStatusCode.InternalServerError); var (client, _) = BuildError(HttpStatusCode.InternalServerError);
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes()))) await client.Invoking(c => c.ValidateAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }
// ── ValidatePdfAsync (byte[]) ──────────────────────────────────────────── // ── ValidateAsync (byte[]) ────────────────────────────────────────────
[Fact] [Fact]
public async Task ValidatePdfAsync_Bytes_SendsJsonWithBase64() public async Task ValidateAsync_Bytes_SendsJsonWithBase64()
{ {
var expected = new PdfValidationResult(PageCount: 1, FileSizeBytes: 0, FileSizeMB: 0, PdfVersion: "", HasAttachments: true, AttachmentCount: 2, IsEncrypted: false); var expected = new PdfValidationResult(PageCount: 1, FileSizeBytes: 0, FileSizeMB: 0, PdfVersion: "", HasAttachments: true, AttachmentCount: 2, IsEncrypted: false);
var (client, handler) = Build(expected); var (client, handler) = Build(expected);
var result = await client.ValidatePdfAsync(FakePdfBytes()); var result = await client.ValidateAsync(FakePdfBytes());
result.PageCount.Should().Be(1); result.PageCount.Should().Be(1);
result.HasAttachments.Should().BeTrue(); result.HasAttachments.Should().BeTrue();
@@ -105,10 +105,10 @@ public class PdfValidationClientTests
} }
[Fact] [Fact]
public async Task ValidatePdfAsync_Bytes_WhenApiReturns400_ThrowsHttpRequestException() public async Task ValidateAsync_Bytes_WhenApiReturns400_ThrowsHttpRequestException()
{ {
var (client, _) = BuildError(HttpStatusCode.BadRequest); var (client, _) = BuildError(HttpStatusCode.BadRequest);
await client.Invoking(c => c.ValidatePdfAsync(FakePdfBytes())) await client.Invoking(c => c.ValidateAsync(FakePdfBytes()))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }

View File

@@ -11,7 +11,7 @@ namespace DocumentService.Tests.Unit.Client;
/// <summary> /// <summary>
/// Unit tests for <see cref="SwissQrCodeClient"/>. /// Unit tests for <see cref="SwissQrCodeClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made. /// All tests use a fake <see cref="MockHttpMessageHandler"/> <EFBFBD> no real HTTP calls are made.
/// </summary> /// </summary>
public class SwissQrCodeClientTests public class SwissQrCodeClientTests
{ {
@@ -27,10 +27,10 @@ public class SwissQrCodeClientTests
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray(); private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
// ?? ExtractSwissQrCodeAsync (Stream) — parsed Bill ?????????????????????? // ?? ExtractAsync (Stream) <EFBFBD> parsed Bill ??????????????????????
[Fact] [Fact]
public async Task ExtractSwissQrCodeAsync_Stream_ParsedMode_SendsMultipartToCorrectEndpoint() public async Task ExtractAsync_Stream_ParsedMode_SendsMultipartToCorrectEndpoint()
{ {
// Arrange // Arrange
var expected = new SwissQrCodeExtractionResult( var expected = new SwissQrCodeExtractionResult(
@@ -47,7 +47,7 @@ public class SwissQrCodeClientTests
var (client, handler) = Build(expected); var (client, handler) = Build(expected);
// Act // Act
var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: false); var result = await client.ExtractAsync(new MemoryStream(FakePdfBytes()), raw: false);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -56,31 +56,31 @@ public class SwissQrCodeClientTests
} }
[Fact] [Fact]
public async Task ExtractSwissQrCodeAsync_Stream_RawMode_SendsRawFlagInUrl() public async Task ExtractAsync_Stream_RawMode_SendsRawFlagInUrl()
{ {
// Arrange // Arrange
var expected = new SwissQrCodeExtractionResult(Bill: null, RawLines: ["SPC", "0200", "1"]); var expected = new SwissQrCodeExtractionResult(Bill: null, RawLines: ["SPC", "0200", "1"]);
var (client, handler) = Build(expected); var (client, handler) = Build(expected);
// Act // Act
var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: true); var result = await client.ExtractAsync(new MemoryStream(FakePdfBytes()), raw: true);
// Assert // Assert
result.RawLines.Should().HaveCount(3).And.StartWith("SPC"); result.RawLines.Should().HaveCount(3).And.StartWith("SPC");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True"); handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
} }
// ?? ExtractSwissQrCodeAsync (byte[]) ???????????????????????????????????? // ?? ExtractAsync (byte[]) ????????????????????????????????????
[Fact] [Fact]
public async Task ExtractSwissQrCodeAsync_Bytes_SendsJsonWithBase64() public async Task ExtractAsync_Bytes_SendsJsonWithBase64()
{ {
// Arrange // Arrange
var expected = new SwissQrCodeExtractionResult(Bill: null, RawLines: []); var expected = new SwissQrCodeExtractionResult(Bill: null, RawLines: []);
var (client, handler) = Build(expected); var (client, handler) = Build(expected);
// Act // Act
await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: false); await client.ExtractAsync(FakePdfBytes(), raw: false);
// Assert // Assert
handler.LastRequest!.Content.Should().NotBeNull(); handler.LastRequest!.Content.Should().NotBeNull();
@@ -91,14 +91,14 @@ public class SwissQrCodeClientTests
} }
[Fact] [Fact]
public async Task ExtractSwissQrCodeAsync_Bytes_RawMode_IncludesRawFlagInUrl() public async Task ExtractAsync_Bytes_RawMode_IncludesRawFlagInUrl()
{ {
// Arrange // Arrange
var expected = new SwissQrCodeExtractionResult(Bill: null, RawLines: ["SPC"]); var expected = new SwissQrCodeExtractionResult(Bill: null, RawLines: ["SPC"]);
var (client, handler) = Build(expected); var (client, handler) = Build(expected);
// Act // Act
await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: true); await client.ExtractAsync(FakePdfBytes(), raw: true);
// Assert // Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True"); handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
@@ -107,7 +107,7 @@ public class SwissQrCodeClientTests
// ?? HTTP error propagation ?????????????????????????????????????????????? // ?? HTTP error propagation ??????????????????????????????????????????????
[Fact] [Fact]
public async Task ExtractSwissQrCodeAsync_WhenApiReturns404_ThrowsHttpRequestException() public async Task ExtractAsync_WhenApiReturns404_ThrowsHttpRequestException()
{ {
// Arrange // Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound); var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
@@ -115,12 +115,12 @@ public class SwissQrCodeClientTests
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance); var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
// Act & Assert // Act & Assert
await client.Invoking(c => c.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()))) await client.Invoking(c => c.ExtractAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }
[Fact] [Fact]
public async Task ExtractSwissQrCodeAsync_WhenApiReturns500_ThrowsHttpRequestException() public async Task ExtractAsync_WhenApiReturns500_ThrowsHttpRequestException()
{ {
// Arrange // Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError); var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
@@ -128,7 +128,7 @@ public class SwissQrCodeClientTests
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance); var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
// Act & Assert // Act & Assert
await client.Invoking(c => c.ExtractSwissQrCodeAsync(FakePdfBytes())) await client.Invoking(c => c.ExtractAsync(FakePdfBytes()))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }
} }

View File

@@ -0,0 +1,257 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using FluentAssertions;
using Moq;
using System.Net.Http;
using System.Reflection;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="WorkflowsClient"/>.
/// Both <see cref="IPdfValidationClient"/> and <see cref="ISwissQrCodeClient"/> are mocked –
/// no real HTTP calls are made. Tests cover every overload, every branch, and all edge cases.
/// </summary>
public class WorkflowsClientTests
{
// ─── test doubles ────────────────────────────────────────────────────────
private readonly Mock<IPdfValidationClient> _validation = new();
private readonly Mock<ISwissQrCodeClient> _swissQrCode = new();
private WorkflowsClient Sut => new(_validation.Object, _swissQrCode.Object);
// Extension methods are defined on IWorkflowsClient — use this for filePath overload tests.
private IWorkflowsClient SutAsInterface => Sut;
// ─── fixture data ─────────────────────────────────────────────────────────
private static byte[] ValidPdfBytes() => LoadTestPdf("valid.pdf");
private static byte[] EncryptedPdfBytes() => LoadTestPdf("encryptedPdf.pdf");
private static byte[] QrPdfBytes() => LoadTestPdf("pdfWithSwissQRCode.pdf");
private static byte[] LoadTestPdf(string filename)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new FileNotFoundException(
$"Embedded resource '{resourceName}' not found. " +
$"Available: {string.Join(", ", assembly.GetManifestResourceNames())}");
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
// ─── valid PdfValidationResult helpers ───────────────────────────────────
private static PdfValidationResult ValidResult() =>
new(PageCount: 1, FileSizeBytes: 1024, FileSizeMB: 0.001,
PdfVersion: "1.4", HasAttachments: false, AttachmentCount: 0,
IsEncrypted: false);
private static PdfValidationResult InvalidResult_Encrypted() =>
new(PageCount: 1, FileSizeBytes: 1024, FileSizeMB: 0.001,
PdfVersion: "1.4", HasAttachments: false, AttachmentCount: 0,
IsEncrypted: true);
private static PdfValidationResult InvalidResult_EmptyFile() =>
new(PageCount: 0, FileSizeBytes: 0, FileSizeMB: 0,
PdfVersion: "", HasAttachments: false, AttachmentCount: 0,
IsEncrypted: false);
private static SwissQrCodeExtractionResult QrResult() =>
new(
Bill: new SwissQrBillDto
{
Version = "V2_0",
Currency = "CHF",
Amount = 630.20m,
Account = "CH9300762011623852957",
Creditor = new AddressDto { Name = "ALMAT AG", Town = "Tagelswangen", CountryCode = "CH", Type = "S", PostalCode = "8317" },
ReferenceType = "QRR",
Reference = "000000000000000000252080824"
},
RawLines: ["SPC", "0200", "1"]
);
private static SwissQrCodeExtractionResult RawQrResult() =>
new(Bill: (SwissQrBillDto?)null, RawLines: ["SPC", "0200", "1", "CH9300762011623852957"]);
[Fact]
public async Task Inspect_Bytes_ValidPdf_ReturnsBothValidationAndQrCode()
{
// Arrange
var pdfBytes = ValidPdfBytes();
_validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(QrResult());
// Act
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes);
// Assert
result.Validation.IsValid.Should().BeTrue();
result.QrCode.Should().NotBeNull();
result.QrCode!.Bill!.Currency.Should().Be("CHF");
_validation.Verify(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()), Times.Once);
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Inspect_Bytes_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows()
{
// Arrange
var pdfBytes = EncryptedPdfBytes();
_validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
// Act
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes);
// Assert – no exception, validation details always available
result.Validation.IsValid.Should().BeFalse();
result.Validation.IsEncrypted.Should().BeTrue();
result.QrCode.Should().BeNull("extraction must not run on an invalid document");
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Inspect_Bytes_ValidationAlwaysPopulated_RegardlessOfQrOutcome()
{
// Arrange – valid PDF but QR extraction returns empty lines (no QR code found)
var pdfBytes = ValidPdfBytes();
var emptyQr = new SwissQrCodeExtractionResult(Bill: (SwissQrBillDto?)null, RawLines: []);
_validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(emptyQr);
// Act
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes);
// Assert – validation is always there even when QR is empty
result.Validation.Should().NotBeNull();
result.Validation.PageCount.Should().Be(1);
result.QrCode.Should().NotBeNull(); // extraction was attempted
}
[Fact]
public async Task Inspect_Bytes_RawMode_PassesRawFlagToExtraction()
{
// Arrange
var pdfBytes = QrPdfBytes();
_validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, true, It.IsAny<CancellationToken>()))
.ReturnsAsync(RawQrResult());
// Act
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes, raw: true);
// Assert
result.QrCode!.RawLines.Should().Contain("SPC");
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, true, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Inspect_Bytes_CancellationToken_ForwardedToBothClients()
{
// Arrange
var pdfBytes = ValidPdfBytes();
var cts = new CancellationTokenSource();
var token = cts.Token;
_validation.Setup(v => v.ValidateAsync(pdfBytes, token)).ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, token)).ReturnsAsync(QrResult());
// Act
await Sut.InspectSwissQrCodeAsync(pdfBytes, cancellationToken: token);
// Assert
_validation.Verify(v => v.ValidateAsync(pdfBytes, token), Times.Once);
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, token), Times.Once);
}
[Fact]
public async Task Inspect_Stream_ValidPdf_ReturnsBothResults()
{
// Arrange
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()))
.ReturnsAsync(QrResult());
using var pdfStream = new MemoryStream(ValidPdfBytes());
// Act
var result = await Sut.InspectSwissQrCodeAsync(pdfStream);
// Assert
result.Validation.IsValid.Should().BeTrue();
result.QrCode.Should().NotBeNull();
}
[Fact]
public async Task Inspect_Stream_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows()
{
// Arrange
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
using var pdfStream = new MemoryStream(EncryptedPdfBytes());
// Act
var result = await Sut.InspectSwissQrCodeAsync(pdfStream);
// Assert
result.Validation.IsValid.Should().BeFalse();
result.QrCode.Should().BeNull();
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Inspect_FilePath_ValidPdf_ReturnsBothResults()
{
// Arrange – extension opens a FileStream so mock expects Stream overload
var tempPath = Path.GetTempFileName();
try
{
await File.WriteAllBytesAsync(tempPath, ValidPdfBytes());
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()))
.ReturnsAsync(QrResult());
// Act – calls extension method which opens a FileStream and delegates to Stream overload
var result = await SutAsInterface.InspectSwissQrCodeAsync(tempPath);
// Assert
result.Validation.IsValid.Should().BeTrue();
result.QrCode.Should().NotBeNull();
}
finally { File.Delete(tempPath); }
}
[Fact]
public async Task Inspect_FilePath_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows()
{
// Arrange
var tempPath = Path.GetTempFileName();
try
{
await File.WriteAllBytesAsync(tempPath, EncryptedPdfBytes());
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
// Act
var result = await SutAsInterface.InspectSwissQrCodeAsync(tempPath);
// Assert – never throws, validation details always populated
result.Validation.IsValid.Should().BeFalse();
result.QrCode.Should().BeNull();
}
finally { File.Delete(tempPath); }
}
}

View File

@@ -12,7 +12,7 @@ namespace DocumentService.Tests.Unit.Client;
/// <summary> /// <summary>
/// Unit tests for <see cref="ZugferdClient"/>. /// Unit tests for <see cref="ZugferdClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made. /// All tests use a fake <see cref="MockHttpMessageHandler"/> <EFBFBD> no real HTTP calls are made.
/// </summary> /// </summary>
public class ZugferdClientTests public class ZugferdClientTests
{ {
@@ -37,17 +37,17 @@ public class ZugferdClientTests
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray(); private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
private static byte[] FakeXmlBytes() => "<invoice>test</invoice>"u8.ToArray(); private static byte[] FakeXmlBytes() => "<invoice>test</invoice>"u8.ToArray();
// ?? HasZugferdAsync (Stream) ????????????????????????????????????????????? // ?? CheckAsync (Stream) ?????????????????????????????????????????????
[Fact] [Fact]
public async Task HasZugferdAsync_Stream_WhenZugferdPresent_ReturnsTrue() public async Task CheckAsync_Stream_WhenZugferdPresent_ReturnsTrue()
{ {
// Arrange // Arrange
var expected = new ZugferdCheckResult { HasZugferd = true, ZugferdFileName = "factur-x.xml" }; var expected = new ZugferdCheckResult { HasZugferd = true, ZugferdFileName = "factur-x.xml" };
var (client, handler) = BuildJson(expected); var (client, handler) = BuildJson(expected);
// Act // Act
var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes())); var result = await client.CheckAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.HasZugferd.Should().BeTrue(); result.HasZugferd.Should().BeTrue();
@@ -56,31 +56,31 @@ public class ZugferdClientTests
} }
[Fact] [Fact]
public async Task HasZugferdAsync_Stream_WhenNoZugferd_ReturnsFalse() public async Task CheckAsync_Stream_WhenNoZugferd_ReturnsFalse()
{ {
// Arrange // Arrange
var expected = new ZugferdCheckResult { HasZugferd = false }; var expected = new ZugferdCheckResult { HasZugferd = false };
var (client, _) = BuildJson(expected); var (client, _) = BuildJson(expected);
// Act // Act
var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes())); var result = await client.CheckAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.HasZugferd.Should().BeFalse(); result.HasZugferd.Should().BeFalse();
result.ZugferdFileName.Should().BeNull(); result.ZugferdFileName.Should().BeNull();
} }
// ?? HasZugferdAsync (byte[]) ????????????????????????????????????????????? // ?? CheckAsync (byte[]) ?????????????????????????????????????????????
[Fact] [Fact]
public async Task HasZugferdAsync_Bytes_SendsJsonWithBase64() public async Task CheckAsync_Bytes_SendsJsonWithBase64()
{ {
// Arrange // Arrange
var expected = new ZugferdCheckResult { HasZugferd = true }; var expected = new ZugferdCheckResult { HasZugferd = true };
var (client, handler) = BuildJson(expected); var (client, handler) = BuildJson(expected);
// Act // Act
await client.HasZugferdAsync(FakePdfBytes()); await client.CheckAsync(FakePdfBytes());
// Assert // Assert
handler.LastRequest!.Content.Should().NotBeNull(); handler.LastRequest!.Content.Should().NotBeNull();
@@ -90,16 +90,16 @@ public class ZugferdClientTests
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty(); doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
} }
// ?? ExtractZugferdAsync (Stream) — raw XML stream ????????????????????????? // ?? ExtractAsync (Stream) <EFBFBD> raw XML stream ?????????????????????????
[Fact] [Fact]
public async Task ExtractZugferdAsync_Stream_SendsMultipartWithAsFileTrue() public async Task ExtractAsync_Stream_SendsMultipartWithAsFileTrue()
{ {
// Arrange // Arrange
var (client, handler) = BuildBytes(FakeXmlBytes()); var (client, handler) = BuildBytes(FakeXmlBytes());
// Act // Act
using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes())); using var result = await client.ExtractAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
@@ -108,30 +108,30 @@ public class ZugferdClientTests
} }
[Fact] [Fact]
public async Task ExtractZugferdAsync_Stream_ReturnsXmlContent() public async Task ExtractAsync_Stream_ReturnsXmlContent()
{ {
// Arrange // Arrange
var xmlBytes = "<root><invoice/></root>"u8.ToArray(); var xmlBytes = "<root><invoice/></root>"u8.ToArray();
var (client, _) = BuildBytes(xmlBytes); var (client, _) = BuildBytes(xmlBytes);
// Act // Act
using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes())); using var result = await client.ExtractAsync(new MemoryStream(FakePdfBytes()));
var content = await new StreamReader(result).ReadToEndAsync(); var content = await new StreamReader(result).ReadToEndAsync();
// Assert // Assert
content.Should().Be("<root><invoice/></root>"); content.Should().Be("<root><invoice/></root>");
} }
// ?? ExtractZugferdAsync (byte[]) ????????????????????????????????????????? // ?? ExtractAsync (byte[]) ?????????????????????????????????????????
[Fact] [Fact]
public async Task ExtractZugferdAsync_Bytes_SendsJsonWithFormatFile() public async Task ExtractAsync_Bytes_SendsJsonWithFormatFile()
{ {
// Arrange // Arrange
var (client, handler) = BuildBytes(FakeXmlBytes()); var (client, handler) = BuildBytes(FakeXmlBytes());
// Act // Act
using var result = await client.ExtractZugferdAsync(FakePdfBytes()); using var result = await client.ExtractAsync(FakePdfBytes());
// Assert // Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=file"); handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=file");
@@ -139,10 +139,10 @@ public class ZugferdClientTests
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json"); handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
} }
// ?? ExtractZugferdAsResultAsync (Stream) ????????????????????????????????? // ?? ExtractAsResultAsync (Stream) ?????????????????????????????????
[Fact] [Fact]
public async Task ExtractZugferdAsResultAsync_Stream_ReturnsStructuredResult() public async Task ExtractAsResultAsync_Stream_ReturnsStructuredResult()
{ {
// Arrange // Arrange
var expected = new ZugferdExtractionResult var expected = new ZugferdExtractionResult
@@ -153,7 +153,7 @@ public class ZugferdClientTests
var (client, handler) = BuildJson(expected); var (client, handler) = BuildJson(expected);
// Act // Act
var result = await client.ExtractZugferdAsResultAsync(new MemoryStream(FakePdfBytes())); var result = await client.ExtractAsResultAsync(new MemoryStream(FakePdfBytes()));
// Assert // Assert
result.FileName.Should().Be("factur-x.xml"); result.FileName.Should().Be("factur-x.xml");
@@ -161,17 +161,17 @@ public class ZugferdClientTests
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=false"); handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=false");
} }
// ?? ExtractZugferdAsResultAsync (byte[]) ????????????????????????????????? // ?? ExtractAsResultAsync (byte[]) ?????????????????????????????????
[Fact] [Fact]
public async Task ExtractZugferdAsResultAsync_Bytes_SendsJsonWithFormatJson() public async Task ExtractAsResultAsync_Bytes_SendsJsonWithFormatJson()
{ {
// Arrange // Arrange
var expected = new ZugferdExtractionResult { FileName = "zugferd.xml", XmlContent = "<x/>" }; var expected = new ZugferdExtractionResult { FileName = "zugferd.xml", XmlContent = "<x/>" };
var (client, handler) = BuildJson(expected); var (client, handler) = BuildJson(expected);
// Act // Act
await client.ExtractZugferdAsResultAsync(FakePdfBytes()); await client.ExtractAsResultAsync(FakePdfBytes());
// Assert // Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=json"); handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=json");
@@ -182,7 +182,7 @@ public class ZugferdClientTests
// ?? HTTP error propagation ??????????????????????????????????????????????? // ?? HTTP error propagation ???????????????????????????????????????????????
[Fact] [Fact]
public async Task HasZugferdAsync_WhenApiReturns400_ThrowsHttpRequestException() public async Task CheckAsync_WhenApiReturns400_ThrowsHttpRequestException()
{ {
// Arrange // Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest); var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
@@ -190,7 +190,7 @@ public class ZugferdClientTests
var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance); var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance);
// Act & Assert // Act & Assert
await client.Invoking(c => c.HasZugferdAsync(new MemoryStream(FakePdfBytes()))) await client.Invoking(c => c.CheckAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>(); .Should().ThrowAsync<HttpRequestException>();
} }
} }

View File

@@ -40,36 +40,116 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentService.Domain", "D
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentService.Tests", "DocumentService.Tests\DocumentService.Tests.csproj", "{ECD5BC60-7009-4099-7193-526FDC6E8D99}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentService.Tests", "DocumentService.Tests\DocumentService.Tests.csproj", "{ECD5BC60-7009-4099-7193-526FDC6E8D99}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentService.Tests.Live", "DocumentService.Tests.Live\DocumentService.Tests.Live.csproj", "{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocumentService.Tests.NuGet", "DocumentService.Tests.NuGet\DocumentService.Tests.NuGet.csproj", "{EE3E7C50-E044-4F77-BB19-74286DDA43F3}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|Any CPU.ActiveCfg = Release|Any CPU {626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|Any CPU.Build.0 = Release|Any CPU {626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|Any CPU.Build.0 = Release|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|x64.ActiveCfg = Debug|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|x64.Build.0 = Debug|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|x86.ActiveCfg = Debug|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Debug|x86.Build.0 = Debug|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|Any CPU.ActiveCfg = Release|Any CPU {626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|Any CPU.Build.0 = Release|Any CPU {626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|Any CPU.Build.0 = Release|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|x64.ActiveCfg = Release|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|x64.Build.0 = Release|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|x86.ActiveCfg = Release|Any CPU
{626E2002-8EC1-4FF9-A9A6-51D5809E26FC}.Release|x86.Build.0 = Release|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|x64.ActiveCfg = Debug|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|x64.Build.0 = Debug|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|x86.ActiveCfg = Debug|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Debug|x86.Build.0 = Debug|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|Any CPU.ActiveCfg = Release|Any CPU {3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|Any CPU.Build.0 = Release|Any CPU {3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|Any CPU.Build.0 = Release|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|x64.ActiveCfg = Release|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|x64.Build.0 = Release|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|x86.ActiveCfg = Release|Any CPU
{3899542A-D6B3-5FF3-2493-093DE5BB0CEB}.Release|x86.Build.0 = Release|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|x64.Build.0 = Debug|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Debug|x86.Build.0 = Debug|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|Any CPU.ActiveCfg = Release|Any CPU {A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|Any CPU.Build.0 = Release|Any CPU {A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|Any CPU.Build.0 = Release|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|x64.ActiveCfg = Release|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|x64.Build.0 = Release|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|x86.ActiveCfg = Release|Any CPU
{A5AB3D2B-D4A8-1BE2-CACE-B5DBF9A40E51}.Release|x86.Build.0 = Release|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|Any CPU.Build.0 = Debug|Any CPU {459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|x64.ActiveCfg = Debug|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|x64.Build.0 = Debug|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|x86.ActiveCfg = Debug|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Debug|x86.Build.0 = Debug|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|Any CPU.ActiveCfg = Release|Any CPU {459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|Any CPU.Build.0 = Release|Any CPU {459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|Any CPU.Build.0 = Release|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|x64.ActiveCfg = Release|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|x64.Build.0 = Release|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|x86.ActiveCfg = Release|Any CPU
{459EDCC5-240E-A2BC-1330-B5B2326B679C}.Release|x86.Build.0 = Release|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|Any CPU.Build.0 = Debug|Any CPU {2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|x64.ActiveCfg = Debug|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|x64.Build.0 = Debug|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|x86.ActiveCfg = Debug|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Debug|x86.Build.0 = Debug|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|Any CPU.ActiveCfg = Release|Any CPU {2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|Any CPU.Build.0 = Release|Any CPU {2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|Any CPU.Build.0 = Release|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|x64.ActiveCfg = Release|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|x64.Build.0 = Release|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|x86.ActiveCfg = Release|Any CPU
{2F2C937F-3732-4E6C-9720-FAB62DC33C63}.Release|x86.Build.0 = Release|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|Any CPU.Build.0 = Debug|Any CPU {ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|x64.ActiveCfg = Debug|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|x64.Build.0 = Debug|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|x86.ActiveCfg = Debug|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Debug|x86.Build.0 = Debug|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|Any CPU.ActiveCfg = Release|Any CPU {ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|Any CPU.Build.0 = Release|Any CPU {ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|Any CPU.Build.0 = Release|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|x64.ActiveCfg = Release|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|x64.Build.0 = Release|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|x86.ActiveCfg = Release|Any CPU
{ECD5BC60-7009-4099-7193-526FDC6E8D99}.Release|x86.Build.0 = Release|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Debug|x64.ActiveCfg = Debug|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Debug|x64.Build.0 = Debug|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Debug|x86.ActiveCfg = Debug|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Debug|x86.Build.0 = Debug|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Release|Any CPU.Build.0 = Release|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Release|x64.ActiveCfg = Release|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Release|x64.Build.0 = Release|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Release|x86.ActiveCfg = Release|Any CPU
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487}.Release|x86.Build.0 = Release|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Debug|x64.ActiveCfg = Debug|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Debug|x64.Build.0 = Debug|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Debug|x86.ActiveCfg = Debug|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Debug|x86.Build.0 = Debug|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Release|Any CPU.Build.0 = Release|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Release|x64.ActiveCfg = Release|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Release|x64.Build.0 = Release|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Release|x86.ActiveCfg = Release|Any CPU
{EE3E7C50-E044-4F77-BB19-74286DDA43F3}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -85,6 +165,8 @@ Global
{459EDCC5-240E-A2BC-1330-B5B2326B679C} = {0B80DDC4-A13C-4C5F-8520-AA2F3650E884} {459EDCC5-240E-A2BC-1330-B5B2326B679C} = {0B80DDC4-A13C-4C5F-8520-AA2F3650E884}
{2F2C937F-3732-4E6C-9720-FAB62DC33C63} = {0B80DDC4-A13C-4C5F-8520-AA2F3650E884} {2F2C937F-3732-4E6C-9720-FAB62DC33C63} = {0B80DDC4-A13C-4C5F-8520-AA2F3650E884}
{ECD5BC60-7009-4099-7193-526FDC6E8D99} = {D64772B8-6213-4031-8064-2567F1E724F6} {ECD5BC60-7009-4099-7193-526FDC6E8D99} = {D64772B8-6213-4031-8064-2567F1E724F6}
{CC5A7D7B-E998-44B2-98CB-B2CF53D4A487} = {D64772B8-6213-4031-8064-2567F1E724F6}
{EE3E7C50-E044-4F77-BB19-74286DDA43F3} = {D64772B8-6213-4031-8064-2567F1E724F6}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {832CA90A-06D6-4312-9B35-16CC665EB37C} SolutionGuid = {832CA90A-06D6-4312-9B35-16CC665EB37C}

0
Program.cs Normal file
View File