diff --git a/DocumentOperator.Tests/Unit/Client/MockHttpMessageHandler.cs b/DocumentOperator.Tests/Unit/Client/MockHttpMessageHandler.cs
new file mode 100644
index 0000000..230beb7
--- /dev/null
+++ b/DocumentOperator.Tests/Unit/Client/MockHttpMessageHandler.cs
@@ -0,0 +1,56 @@
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+
+namespace DocumentService.Tests.Unit.Client;
+
+///
+/// Reusable fake for unit-testing HTTP clients.
+/// Captures the outgoing request and returns the configured response.
+///
+internal sealed class MockHttpMessageHandler : HttpMessageHandler
+{
+ private readonly HttpResponseMessage _response;
+
+ /// The last request that was sent through this handler.
+ public HttpRequestMessage? LastRequest { get; private set; }
+
+ public MockHttpMessageHandler(HttpResponseMessage response)
+ {
+ _response = response;
+ }
+
+ // ?? convenience factories ????????????????????????????????????????????????
+
+ /// Creates a handler that returns 200 OK with a JSON-serialised body.
+ public static MockHttpMessageHandler ReturningJson(T body, HttpStatusCode status = HttpStatusCode.OK)
+ {
+ var json = JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+ var response = new HttpResponseMessage(status)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ return new MockHttpMessageHandler(response);
+ }
+
+ /// Creates a handler that returns 200 OK with raw bytes as the body.
+ public static MockHttpMessageHandler ReturningBytes(byte[] bytes, string mediaType = "application/octet-stream", HttpStatusCode status = HttpStatusCode.OK)
+ {
+ var response = new HttpResponseMessage(status)
+ {
+ Content = new ByteArrayContent(bytes) { Headers = { ContentType = new(mediaType) } }
+ };
+ return new MockHttpMessageHandler(response);
+ }
+
+ /// Creates a handler that returns the given status code with no body.
+ public static MockHttpMessageHandler ReturningStatus(HttpStatusCode status)
+ => new(new HttpResponseMessage(status));
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequest = request;
+ return Task.FromResult(_response);
+ }
+}
diff --git a/DocumentOperator.Tests/Unit/Client/PdfAttachmentClientTests.cs b/DocumentOperator.Tests/Unit/Client/PdfAttachmentClientTests.cs
new file mode 100644
index 0000000..81c9d65
--- /dev/null
+++ b/DocumentOperator.Tests/Unit/Client/PdfAttachmentClientTests.cs
@@ -0,0 +1,221 @@
+using DocumentService.Client.Clients;
+using DocumentService.Client.Interfaces;
+using DocumentService.Client.Models.Requests;
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.IO.Compression;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+
+namespace DocumentService.Tests.Unit.Client;
+
+///
+/// Unit tests for .
+/// All tests use a fake — no real HTTP calls are made.
+///
+public class PdfAttachmentClientTests
+{
+ // ?? helpers ?????????????????????????????????????????????????????????????
+
+ private static (PdfAttachmentClient client, MockHttpMessageHandler handler) BuildJson(T body)
+ {
+ var handler = MockHttpMessageHandler.ReturningJson(body);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfAttachmentClient(httpClient, NullLogger.Instance);
+ return (client, handler);
+ }
+
+ private static (PdfAttachmentClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes, string mediaType = "application/zip")
+ {
+ var handler = MockHttpMessageHandler.ReturningBytes(bytes, mediaType);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfAttachmentClient(httpClient, NullLogger.Instance);
+ return (client, handler);
+ }
+
+ private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
+
+ /// Builds a minimal valid ZIP containing the given entries.
+ private static byte[] BuildZip(Dictionary entries)
+ {
+ using var ms = new MemoryStream();
+ using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ foreach (var (name, content) in entries)
+ {
+ var entry = archive.CreateEntry(name);
+ using var writer = new StreamWriter(entry.Open());
+ writer.Write(content);
+ }
+ }
+ return ms.ToArray();
+ }
+
+ // ?? CheckAttachmentsAsync (Stream) ???????????????????????????????????????
+
+ [Fact]
+ public async Task CheckAttachmentsAsync_Stream_SendsMultipartPost()
+ {
+ // Arrange
+ var expected = new AttachmentCheckResult
+ {
+ HasAttachments = true,
+ AttachmentCount = 1,
+ Attachments = new List
+ {
+ new() { FileName = "factur-x.xml", MimeType = "application/xml", Size = 512 }
+ }
+ };
+ var (client, handler) = BuildJson(expected);
+
+ // Act
+ var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.HasAttachments.Should().BeTrue();
+ result.AttachmentCount.Should().Be(1);
+ result.Attachments[0].FileName.Should().Be("factur-x.xml");
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/attachments/check");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task CheckAttachmentsAsync_Stream_PdfWithNoAttachments_ReturnsFalse()
+ {
+ // Arrange
+ var expected = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0 };
+ var (client, _) = BuildJson(expected);
+
+ // Act
+ var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.HasAttachments.Should().BeFalse();
+ result.AttachmentCount.Should().Be(0);
+ result.Attachments.Should().BeEmpty();
+ }
+
+ // ?? CheckAttachmentsAsync (byte[]) ???????????????????????????????????????
+
+ [Fact]
+ public async Task CheckAttachmentsAsync_Bytes_SendsJsonWithBase64()
+ {
+ // Arrange
+ var expected = new AttachmentCheckResult { HasAttachments = false };
+ var (client, handler) = BuildJson(expected);
+
+ // Act
+ await client.CheckAttachmentsAsync(FakePdfBytes());
+
+ // Assert
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ var body = await handler.LastRequest.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
+ }
+
+ // ?? ExtractAttachmentsAsync (Stream) — ZIP unzip ?????????????????????????
+
+ [Fact]
+ public async Task ExtractAttachmentsAsync_Stream_UnzipsAndReturnsDictionary()
+ {
+ // Arrange
+ var zipBytes = BuildZip(new Dictionary
+ {
+ ["factur-x.xml"] = "test",
+ ["readme.txt"] = "Hello World"
+ });
+ var (client, handler) = BuildBytes(zipBytes);
+
+ // Act
+ var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Should().ContainKey("factur-x.xml");
+ result.Should().ContainKey("readme.txt");
+
+ using var xmlStream = result["factur-x.xml"];
+ var xmlContent = await new StreamReader(xmlStream).ReadToEndAsync();
+ xmlContent.Should().Be("test");
+
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/attachments/extract");
+
+ // Cleanup
+ foreach (var s in result.Values) s.Dispose();
+ }
+
+ [Fact]
+ public async Task ExtractAttachmentsAsync_Stream_WithSingleEntry_ReturnsOneItem()
+ {
+ // Arrange
+ var zipBytes = BuildZip(new Dictionary
+ {
+ ["data.xml"] = ""
+ });
+ var (client, _) = BuildBytes(zipBytes);
+
+ // Act
+ var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.Should().HaveCount(1);
+ result.Should().ContainKey("data.xml");
+
+ foreach (var s in result.Values) s.Dispose();
+ }
+
+ // ?? ExtractAttachmentsAsync (byte[]) — ZIP unzip ?????????????????????????
+
+ [Fact]
+ public async Task ExtractAttachmentsAsync_Bytes_SendsJsonAndUnzips()
+ {
+ // Arrange
+ var zipBytes = BuildZip(new Dictionary
+ {
+ ["invoice.xml"] = ""
+ });
+ var (client, handler) = BuildBytes(zipBytes);
+
+ // Act
+ var result = await client.ExtractAttachmentsAsync(FakePdfBytes());
+
+ // Assert
+ result.Should().ContainKey("invoice.xml");
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+
+ foreach (var s in result.Values) s.Dispose();
+ }
+
+ // ?? HTTP error propagation ???????????????????????????????????????????????
+
+ [Fact]
+ public async Task CheckAttachmentsAsync_WhenApiReturns404_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfAttachmentClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes())))
+ .Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task ExtractAttachmentsAsync_WhenApiReturns500_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfAttachmentClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes())))
+ .Should().ThrowAsync();
+ }
+}
diff --git a/DocumentOperator.Tests/Unit/Client/PdfOperationsClientTests.cs b/DocumentOperator.Tests/Unit/Client/PdfOperationsClientTests.cs
new file mode 100644
index 0000000..9288c15
--- /dev/null
+++ b/DocumentOperator.Tests/Unit/Client/PdfOperationsClientTests.cs
@@ -0,0 +1,236 @@
+using DocumentService.Client.Clients;
+using DocumentService.Client.Interfaces;
+using DocumentService.Client.Models.Requests;
+using DocumentService.Client.Models.ValueObjects;
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+
+namespace DocumentService.Tests.Unit.Client;
+
+///
+/// Unit tests for .
+/// All tests use a fake — no real HTTP calls are made.
+///
+public class PdfOperationsClientTests
+{
+ // ?? helpers ?????????????????????????????????????????????????????????????
+
+ private static (PdfOperationsClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes = null!)
+ {
+ var handler = MockHttpMessageHandler.ReturningBytes(bytes ?? "merged-pdf"u8.ToArray(), "application/pdf");
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfOperationsClient(httpClient, NullLogger.Instance);
+ return (client, handler);
+ }
+
+ private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
+
+ private static AddAnnotationBase64Request FakeAnnotationRequest() => new()
+ {
+ Base64Pdf = string.Empty,
+ AnnotationType = AnnotationType.TextMarkup,
+ PageNumber = 1,
+ X1 = 10, Y1 = 20, Width = 100, Height = 30,
+ Color = "FFFF00",
+ TextMarkupStyle = TextMarkupStyle.Highlight,
+ Origin = AnnotationOrigin.TopLeft
+ };
+
+ private static AddStampBase64Request FakeStampRequest() => new()
+ {
+ Base64Pdf = string.Empty,
+ StampType = StampType.Text,
+ X = 100, Y = 50,
+ Text = "CONFIDENTIAL",
+ FontSize = 24,
+ Color = "FF0000",
+ Opacity = 0.5,
+ Placement = StampPlacement.Foreground,
+ Origin = AnnotationOrigin.BottomLeft
+ };
+
+ // ?? MergeAsync (Streams) ?????????????????????????????????????????????????
+
+ [Fact]
+ public async Task MergeAsync_Streams_SendsMultipartWithAllFiles()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes();
+ var streams = new List
+ {
+ new MemoryStream(FakePdfBytes()),
+ new MemoryStream(FakePdfBytes())
+ };
+
+ // Act
+ using var result = await client.MergeAsync(streams);
+
+ // Assert
+ result.Should().NotBeNull();
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/merge");
+ handler.LastRequest.Content.Should().BeOfType();
+
+ foreach (var s in streams) s.Dispose();
+ }
+
+ [Fact]
+ public async Task MergeAsync_Streams_ReturnsMergedPdfStream()
+ {
+ // Arrange
+ var expectedBytes = "merged-content"u8.ToArray();
+ var (client, _) = BuildBytes(expectedBytes);
+
+ // Act
+ using var result = await client.MergeAsync(new[] { new MemoryStream(FakePdfBytes()), new MemoryStream(FakePdfBytes()) });
+ var actualBytes = await result.ReadAllBytesAsync();
+
+ // Assert
+ actualBytes.Should().BeEquivalentTo(expectedBytes);
+ }
+
+ // ?? MergeAsync (byte[][]) ????????????????????????????????????????????????
+
+ [Fact]
+ public async Task MergeAsync_ByteArrays_SendsJsonWithBase64List()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes();
+
+ // Act
+ using var result = await client.MergeAsync(new[] { FakePdfBytes(), FakePdfBytes() });
+
+ // Assert
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ var body = await handler.LastRequest.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("base64Pdfs").GetArrayLength().Should().Be(2);
+ }
+
+ [Fact]
+ public async Task MergeAsync_ByteArrays_WithPageRanges_IncludesPageRangesInJson()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes();
+ var pageRanges = new List { "1-2", null };
+
+ // Act
+ await client.MergeAsync(new[] { FakePdfBytes(), FakePdfBytes() }, pageRanges);
+
+ // Assert
+ handler.LastRequest!.Content.Should().NotBeNull();
+ var body = await handler.LastRequest!.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("pageRanges").GetArrayLength().Should().Be(2);
+ }
+
+ // ?? AnnotateAsync (Stream) ????????????????????????????????????????????????
+
+ [Fact]
+ public async Task AnnotateAsync_Stream_SendsMultipartWithAnnotationFields()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes();
+ var request = FakeAnnotationRequest();
+
+ // Act
+ using var result = await client.AnnotateAsync(new MemoryStream(FakePdfBytes()), request);
+
+ // Assert
+ result.Should().NotBeNull();
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/annotate");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ // ?? AnnotateAsync (byte[]) ????????????????????????????????????????????????
+
+ [Fact]
+ public async Task AnnotateAsync_Bytes_SendsJsonWithBase64Pdf()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes();
+ var request = FakeAnnotationRequest();
+
+ // Act
+ await client.AnnotateAsync(FakePdfBytes(), request);
+
+ // Assert
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ var body = await handler.LastRequest.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
+ // annotationType serializes as integer by default (TextMarkup = 0)
+ doc.RootElement.GetProperty("annotationType").GetInt32().Should().Be((int)AnnotationType.TextMarkup);
+ }
+
+ // ?? StampAsync (Stream) ???????????????????????????????????????????????????
+
+ [Fact]
+ public async Task StampAsync_Stream_SendsMultipartWithStampFields()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes();
+ var request = FakeStampRequest();
+
+ // Act
+ using var result = await client.StampAsync(new MemoryStream(FakePdfBytes()), request);
+
+ // Assert
+ result.Should().NotBeNull();
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/stamp");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ // ?? StampAsync (byte[]) ???????????????????????????????????????????????????
+
+ [Fact]
+ public async Task StampAsync_Bytes_SendsJsonWithBase64Pdf()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes();
+ var request = FakeStampRequest();
+
+ // Act
+ await client.StampAsync(FakePdfBytes(), request);
+
+ // Assert
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ var body = await handler.LastRequest.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
+ // stampType serializes as integer by default (Text = 0)
+ doc.RootElement.GetProperty("stampType").GetInt32().Should().Be((int)StampType.Text);
+ }
+
+ // ?? HTTP error propagation ???????????????????????????????????????????????
+
+ [Fact]
+ public async Task MergeAsync_WhenApiReturns400_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfOperationsClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.MergeAsync(new[] { new MemoryStream(FakePdfBytes()), new MemoryStream(FakePdfBytes()) }))
+ .Should().ThrowAsync();
+ }
+}
+
+// ?? local helper extension ???????????????????????????????????????????????????
+
+file static class StreamHelper
+{
+ public static async Task ReadAllBytesAsync(this Stream stream)
+ {
+ using var ms = new MemoryStream();
+ await stream.CopyToAsync(ms);
+ return ms.ToArray();
+ }
+}
diff --git a/DocumentOperator.Tests/Unit/Client/PdfValidationClientTests.cs b/DocumentOperator.Tests/Unit/Client/PdfValidationClientTests.cs
new file mode 100644
index 0000000..dedca82
--- /dev/null
+++ b/DocumentOperator.Tests/Unit/Client/PdfValidationClientTests.cs
@@ -0,0 +1,168 @@
+using DocumentService.Client.Clients;
+using DocumentService.Client.Interfaces;
+using DocumentService.Client.Models.Requests;
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+
+namespace DocumentService.Tests.Unit.Client;
+
+///
+/// Unit tests for .
+/// All tests use a fake — no real HTTP calls are made.
+///
+public class PdfValidationClientTests
+{
+ // ?? helpers ?????????????????????????????????????????????????????????????
+
+ private static (PdfValidationClient client, MockHttpMessageHandler handler) Build(T responseBody)
+ {
+ var handler = MockHttpMessageHandler.ReturningJson(responseBody);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfValidationClient(httpClient, NullLogger.Instance);
+ return (client, handler);
+ }
+
+ private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
+
+ // ?? ValidatePdfAsync (Stream) ????????????????????????????????????????????
+
+ [Fact]
+ public async Task ValidatePdfAsync_Stream_SendsMultipartPost()
+ {
+ // Arrange
+ var expected = new PdfValidationResult { PageCount = 3, PdfVersion = "1.7", FileSizeBytes = 2048 };
+ var (client, handler) = Build(expected);
+
+ // Act
+ var result = await client.ValidatePdfAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.PageCount.Should().Be(3);
+ result.PdfVersion.Should().Be("1.7");
+ handler.LastRequest!.Method.Should().Be(HttpMethod.Post);
+ handler.LastRequest.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ValidatePdfAsync_Stream_ThrowsWhenApiReturnsNull()
+ {
+ // Arrange — API returns JSON null
+ var handler = MockHttpMessageHandler.ReturningJson(null);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfValidationClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
+ .Should().ThrowAsync();
+ }
+
+ // ?? ValidatePdfAsync (byte[]) ????????????????????????????????????????????
+
+ [Fact]
+ public async Task ValidatePdfAsync_Bytes_SendsJsonWithBase64()
+ {
+ // Arrange
+ var expected = new PdfValidationResult { PageCount = 1, IsEncrypted = false };
+ var (client, handler) = Build(expected);
+
+ // Act
+ var result = await client.ValidatePdfAsync(FakePdfBytes());
+
+ // Assert
+ result.PageCount.Should().Be(1);
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+
+ var body = await handler.LastRequest.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
+ }
+
+ // ?? ValidatePdfAAsync (Stream) ???????????????????????????????????????????
+
+ [Fact]
+ public async Task ValidatePdfAAsync_Stream_SendsMultipartPost()
+ {
+ // Arrange
+ var expected = new PdfAValidationResult { IsValid = true, PdfAVersion = "PDF/A-3b", PageCount = 2 };
+ var (client, handler) = Build(expected);
+
+ // Act
+ var result = await client.ValidatePdfAAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.IsValid.Should().BeTrue();
+ result.PdfAVersion.Should().Be("PDF/A-3b");
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate-pdfa");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ValidatePdfAAsync_Stream_WithErrors_ReturnsErrors()
+ {
+ // Arrange
+ var expected = new PdfAValidationResult
+ {
+ IsValid = false,
+ Errors = new List { "Missing embedded font", "Encryption not allowed" }
+ };
+ var (client, _) = Build(expected);
+
+ // Act
+ var result = await client.ValidatePdfAAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().HaveCount(2).And.Contain("Missing embedded font");
+ }
+
+ // ?? ValidatePdfAAsync (byte[]) ???????????????????????????????????????????
+
+ [Fact]
+ public async Task ValidatePdfAAsync_Bytes_SendsJson()
+ {
+ // Arrange
+ var expected = new PdfAValidationResult { IsValid = true };
+ var (client, handler) = Build(expected);
+
+ // Act
+ await client.ValidatePdfAAsync(FakePdfBytes());
+
+ // Assert
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate-pdfa");
+ handler.LastRequest.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ }
+
+ // ?? HTTP error propagation ???????????????????????????????????????????????
+
+ [Fact]
+ public async Task ValidatePdfAsync_WhenApiReturns400_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfValidationClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
+ .Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task ValidatePdfAsync_WhenApiReturns500_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new PdfValidationClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
+ .Should().ThrowAsync();
+ }
+}
diff --git a/DocumentOperator.Tests/Unit/Client/SwissQrCodeClientTests.cs b/DocumentOperator.Tests/Unit/Client/SwissQrCodeClientTests.cs
new file mode 100644
index 0000000..d252a29
--- /dev/null
+++ b/DocumentOperator.Tests/Unit/Client/SwissQrCodeClientTests.cs
@@ -0,0 +1,123 @@
+using DocumentService.Client.Clients;
+using DocumentService.Client.Interfaces;
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+
+namespace DocumentService.Tests.Unit.Client;
+
+///
+/// Unit tests for .
+/// All tests use a fake — no real HTTP calls are made.
+///
+public class SwissQrCodeClientTests
+{
+ // ?? helpers ?????????????????????????????????????????????????????????????
+
+ private static (SwissQrCodeClient client, MockHttpMessageHandler handler) Build(T body)
+ {
+ var handler = MockHttpMessageHandler.ReturningJson(body);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new SwissQrCodeClient(httpClient, NullLogger.Instance);
+ return (client, handler);
+ }
+
+ private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
+
+ // ?? ExtractSwissQrCodeAsync (Stream) — parsed Bill ???????????????????????
+
+ [Fact]
+ public async Task ExtractSwissQrCodeAsync_Stream_ParsedMode_SendsMultipartToCorrectEndpoint()
+ {
+ // Arrange
+ var expected = new SwissQrCodeExtractionResult { Bill = new { Iban = "CH93-0076-2011-6238-5295-7" } };
+ var (client, handler) = Build(expected);
+
+ // Act
+ var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: false);
+
+ // Assert
+ result.Should().NotBeNull();
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=False");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ExtractSwissQrCodeAsync_Stream_RawMode_SendsRawFlagInUrl()
+ {
+ // Arrange
+ var expected = new SwissQrCodeExtractionResult { RawLines = new List { "SPC", "0200", "1" } };
+ var (client, handler) = Build(expected);
+
+ // Act
+ var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: true);
+
+ // Assert
+ result.RawLines.Should().HaveCount(3).And.StartWith("SPC");
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
+ }
+
+ // ?? ExtractSwissQrCodeAsync (byte[]) ?????????????????????????????????????
+
+ [Fact]
+ public async Task ExtractSwissQrCodeAsync_Bytes_SendsJsonWithBase64()
+ {
+ // Arrange
+ var expected = new SwissQrCodeExtractionResult();
+ var (client, handler) = Build(expected);
+
+ // Act
+ await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: false);
+
+ // Assert
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ var body = await handler.LastRequest.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task ExtractSwissQrCodeAsync_Bytes_RawMode_IncludesRawFlagInUrl()
+ {
+ // Arrange
+ var expected = new SwissQrCodeExtractionResult { RawLines = new List { "SPC" } };
+ var (client, handler) = Build(expected);
+
+ // Act
+ await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: true);
+
+ // Assert
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
+ }
+
+ // ?? HTTP error propagation ???????????????????????????????????????????????
+
+ [Fact]
+ public async Task ExtractSwissQrCodeAsync_WhenApiReturns404_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new SwissQrCodeClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes())))
+ .Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task ExtractSwissQrCodeAsync_WhenApiReturns500_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new SwissQrCodeClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.ExtractSwissQrCodeAsync(FakePdfBytes()))
+ .Should().ThrowAsync();
+ }
+}
diff --git a/DocumentOperator.Tests/Unit/Client/ZugferdClientTests.cs b/DocumentOperator.Tests/Unit/Client/ZugferdClientTests.cs
new file mode 100644
index 0000000..cc852c0
--- /dev/null
+++ b/DocumentOperator.Tests/Unit/Client/ZugferdClientTests.cs
@@ -0,0 +1,199 @@
+using DocumentService.Client.Clients;
+using DocumentService.Client.Interfaces;
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+
+namespace DocumentService.Tests.Unit.Client;
+
+///
+/// Unit tests for .
+/// All tests use a fake — no real HTTP calls are made.
+///
+public class ZugferdClientTests
+{
+ // ?? helpers ?????????????????????????????????????????????????????????????
+
+ private static (ZugferdClient client, MockHttpMessageHandler handler) BuildJson(T body)
+ {
+ var handler = MockHttpMessageHandler.ReturningJson(body);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new ZugferdClient(httpClient, NullLogger.Instance);
+ return (client, handler);
+ }
+
+ private static (ZugferdClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes, string mediaType = "application/xml")
+ {
+ var handler = MockHttpMessageHandler.ReturningBytes(bytes, mediaType);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new ZugferdClient(httpClient, NullLogger.Instance);
+ return (client, handler);
+ }
+
+ private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
+ private static byte[] FakeXmlBytes() => "test"u8.ToArray();
+
+ // ?? HasZugferdAsync (Stream) ?????????????????????????????????????????????
+
+ [Fact]
+ public async Task HasZugferdAsync_Stream_WhenZugferdPresent_ReturnsTrue()
+ {
+ // Arrange
+ var expected = new ZugferdCheckResult { HasZugferd = true, Version = "2.1", Profile = "EN 16931" };
+ var (client, handler) = BuildJson(expected);
+
+ // Act
+ var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.HasZugferd.Should().BeTrue();
+ result.Version.Should().Be("2.1");
+ result.Profile.Should().Be("EN 16931");
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/has-zugferd");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task HasZugferdAsync_Stream_WhenNoZugferd_ReturnsFalse()
+ {
+ // Arrange
+ var expected = new ZugferdCheckResult { HasZugferd = false };
+ var (client, _) = BuildJson(expected);
+
+ // Act
+ var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.HasZugferd.Should().BeFalse();
+ result.Version.Should().BeNull();
+ }
+
+ // ?? HasZugferdAsync (byte[]) ?????????????????????????????????????????????
+
+ [Fact]
+ public async Task HasZugferdAsync_Bytes_SendsJsonWithBase64()
+ {
+ // Arrange
+ var expected = new ZugferdCheckResult { HasZugferd = true };
+ var (client, handler) = BuildJson(expected);
+
+ // Act
+ await client.HasZugferdAsync(FakePdfBytes());
+
+ // Assert
+ handler.LastRequest!.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ var body = await handler.LastRequest.Content!.ReadAsStringAsync();
+ var doc = JsonDocument.Parse(body);
+ doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
+ }
+
+ // ?? ExtractZugferdAsync (Stream) — raw XML stream ?????????????????????????
+
+ [Fact]
+ public async Task ExtractZugferdAsync_Stream_SendsMultipartWithAsFileTrue()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes(FakeXmlBytes());
+
+ // Act
+ using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.Should().NotBeNull();
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=true");
+ handler.LastRequest.Content.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ExtractZugferdAsync_Stream_ReturnsXmlContent()
+ {
+ // Arrange
+ var xmlBytes = ""u8.ToArray();
+ var (client, _) = BuildBytes(xmlBytes);
+
+ // Act
+ using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes()));
+ var content = await new StreamReader(result).ReadToEndAsync();
+
+ // Assert
+ content.Should().Be("");
+ }
+
+ // ?? ExtractZugferdAsync (byte[]) ?????????????????????????????????????????
+
+ [Fact]
+ public async Task ExtractZugferdAsync_Bytes_SendsJsonWithFormatFile()
+ {
+ // Arrange
+ var (client, handler) = BuildBytes(FakeXmlBytes());
+
+ // Act
+ using var result = await client.ExtractZugferdAsync(FakePdfBytes());
+
+ // Assert
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=file");
+ handler.LastRequest.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ }
+
+ // ?? ExtractZugferdAsResultAsync (Stream) ?????????????????????????????????
+
+ [Fact]
+ public async Task ExtractZugferdAsResultAsync_Stream_ReturnsStructuredResult()
+ {
+ // Arrange
+ var expected = new ZugferdExtractionResult
+ {
+ FileName = "factur-x.xml",
+ XmlContent = "",
+ Version = "2.1",
+ Profile = "EN 16931"
+ };
+ var (client, handler) = BuildJson(expected);
+
+ // Act
+ var result = await client.ExtractZugferdAsResultAsync(new MemoryStream(FakePdfBytes()));
+
+ // Assert
+ result.FileName.Should().Be("factur-x.xml");
+ result.XmlContent.Should().Be("");
+ result.Version.Should().Be("2.1");
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=false");
+ }
+
+ // ?? ExtractZugferdAsResultAsync (byte[]) ?????????????????????????????????
+
+ [Fact]
+ public async Task ExtractZugferdAsResultAsync_Bytes_SendsJsonWithFormatJson()
+ {
+ // Arrange
+ var expected = new ZugferdExtractionResult { FileName = "zugferd.xml", XmlContent = "" };
+ var (client, handler) = BuildJson(expected);
+
+ // Act
+ await client.ExtractZugferdAsResultAsync(FakePdfBytes());
+
+ // Assert
+ handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=json");
+ handler.LastRequest.Content.Should().NotBeNull();
+ handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
+ }
+
+ // ?? HTTP error propagation ???????????????????????????????????????????????
+
+ [Fact]
+ public async Task HasZugferdAsync_WhenApiReturns400_ThrowsHttpRequestException()
+ {
+ // Arrange
+ var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
+ var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
+ var client = new ZugferdClient(httpClient, NullLogger.Instance);
+
+ // Act & Assert
+ await client.Invoking(c => c.HasZugferdAsync(new MemoryStream(FakePdfBytes())))
+ .Should().ThrowAsync();
+ }
+}