diff --git a/DocumentService.Tests.Live/API/PdfAttachmentLiveTests.cs b/DocumentService.Tests.Live/API/PdfAttachmentLiveTests.cs
new file mode 100644
index 0000000..f92919f
--- /dev/null
+++ b/DocumentService.Tests.Live/API/PdfAttachmentLiveTests.cs
@@ -0,0 +1,235 @@
+using DocumentService.Application.Common.DTOs;
+using FluentAssertions;
+
+namespace DocumentService.Tests.Live.API;
+
+///
+/// Live tests for PDF attachment endpoints (check + extract).
+/// Uses PdfAttachmentClient — routes managed by the client.
+/// Target: http://172.24.12.39:9393/
+///
+[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());
+
+ var act = async () => await AttachmentClient.CheckAsync(stream);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [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(
+ "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());
+
+ await act.Should().ThrowAsync();
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // 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());
+
+ var act = async () => await AttachmentClient.ExtractAsync(stream);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [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();
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // 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());
+
+ await act.Should().ThrowAsync();
+ }
+}
diff --git a/DocumentService.Tests.Live/API/PdfOperationsLiveTests.cs b/DocumentService.Tests.Live/API/PdfOperationsLiveTests.cs
new file mode 100644
index 0000000..d676f88
--- /dev/null
+++ b/DocumentService.Tests.Live/API/PdfOperationsLiveTests.cs
@@ -0,0 +1,532 @@
+using DocumentService.Application.Common.DTOs.Requests;
+using DocumentService.Domain.Models.ValueObjects;
+using FluentAssertions;
+
+namespace DocumentService.Tests.Live.API;
+
+///
+/// Live tests for PDF operations: merge, annotate, stamp.
+/// Uses PdfOperationsClient — routes managed by the client.
+/// Target: http://172.24.12.39:9393/
+///
+[Collection("Live API")]
+public class PdfOperationsLiveTests : LiveApiTestBase
+{
+ // ══════════════════════════════════════════════════════════════
+ // MERGE — MergeAsync(IEnumerable) → 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(
+ "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(
+ "corrupted PDF should return 500");
+ }
+
+ // ══════════════════════════════════════════════════════════════
+ // MERGE — MergeAsync(IEnumerable) → 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();
+ }
+
+ [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(
+ "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(
+ "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(
+ "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(
+ "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(
+ "Text stamp without text should return 400");
+ }
+}
diff --git a/DocumentService.Tests.Live/API/PdfValidationLiveTests.cs b/DocumentService.Tests.Live/API/PdfValidationLiveTests.cs
new file mode 100644
index 0000000..e10f427
--- /dev/null
+++ b/DocumentService.Tests.Live/API/PdfValidationLiveTests.cs
@@ -0,0 +1,196 @@
+using DocumentService.Application.Common.DTOs;
+using FluentAssertions;
+
+namespace DocumentService.Tests.Live.API;
+
+///
+/// Live tests for PDF validation endpoints.
+/// Uses PdfValidationClient — routes are managed by the client, not the test.
+/// Target: http://172.24.12.39:9393/
+///
+[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());
+
+ var act = async () => await ValidationClient.ValidateAsync(stream);
+
+ await act.Should().ThrowAsync(
+ "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(
+ "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());
+
+ await act.Should().ThrowAsync(
+ "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());
+
+ var act = async () => await ValidationClient.ValidatePdfAAsync(stream);
+
+ await act.Should().ThrowAsync();
+ }
+
+ // ──────────────────────────────────────────────────────────────
+ // 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());
+
+ await act.Should().ThrowAsync();
+ }
+}
diff --git a/DocumentService.Tests.Live/API/SwissQrCodeLiveTests.cs b/DocumentService.Tests.Live/API/SwissQrCodeLiveTests.cs
new file mode 100644
index 0000000..2473316
--- /dev/null
+++ b/DocumentService.Tests.Live/API/SwissQrCodeLiveTests.cs
@@ -0,0 +1,129 @@
+using DocumentService.Application.Common.DTOs;
+using FluentAssertions;
+
+namespace DocumentService.Tests.Live.API;
+
+///
+/// Live tests for Swiss QR Code extraction endpoint.
+/// Uses SwissQrCodeClient — routes managed by the client.
+/// Target: http://172.24.12.39:9393/
+///
+[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(
+ "PDF without QR code must return 404");
+ }
+
+ [Fact]
+ public async Task Extract_Stream_EmptyStream_Throws()
+ {
+ using var stream = new MemoryStream(Array.Empty());
+
+ var act = async () => await QrCodeClient.ExtractAsync(stream);
+
+ await act.Should().ThrowAsync();
+ }
+
+ [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(
+ "PDF without QR code must return 404");
+ }
+
+ [Fact]
+ public async Task Extract_Bytes_EmptyArray_Throws()
+ {
+ var act = async () => await QrCodeClient.ExtractAsync(Array.Empty());
+
+ await act.Should().ThrowAsync();
+ }
+}
diff --git a/DocumentService.Tests.Live/DocumentService.Tests.Live.csproj b/DocumentService.Tests.Live/DocumentService.Tests.Live.csproj
new file mode 100644
index 0000000..939da9b
--- /dev/null
+++ b/DocumentService.Tests.Live/DocumentService.Tests.Live.csproj
@@ -0,0 +1,47 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DocumentService.Tests.Live/LiveApiTestBase.cs b/DocumentService.Tests.Live/LiveApiTestBase.cs
new file mode 100644
index 0000000..f61a10d
--- /dev/null
+++ b/DocumentService.Tests.Live/LiveApiTestBase.cs
@@ -0,0 +1,64 @@
+using DocumentService.Client.Clients;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Reflection;
+
+namespace DocumentService.Tests.Live;
+
+///
+/// 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.
+///
+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.Instance);
+ AttachmentClient = new PdfAttachmentClient(HttpClient, NullLogger.Instance);
+ OperationsClient = new PdfOperationsClient(HttpClient, NullLogger.Instance);
+ QrCodeClient = new SwissQrCodeClient(HttpClient, NullLogger.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();
+}
diff --git a/DocumentService.Tests.Live/TestData/Pdfs/encryptedPdf.pdf b/DocumentService.Tests.Live/TestData/Pdfs/encryptedPdf.pdf
new file mode 100644
index 0000000..cd36c06
--- /dev/null
+++ b/DocumentService.Tests.Live/TestData/Pdfs/encryptedPdf.pdf
@@ -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
\ No newline at end of file
diff --git a/DocumentService.Tests.Live/TestData/Pdfs/pdfWithMoreThanOneAttachment.pdf b/DocumentService.Tests.Live/TestData/Pdfs/pdfWithMoreThanOneAttachment.pdf
new file mode 100644
index 0000000..15e173e
Binary files /dev/null and b/DocumentService.Tests.Live/TestData/Pdfs/pdfWithMoreThanOneAttachment.pdf differ
diff --git a/DocumentService.Tests.Live/TestData/Pdfs/pdfWithSwissQRCode.pdf b/DocumentService.Tests.Live/TestData/Pdfs/pdfWithSwissQRCode.pdf
new file mode 100644
index 0000000..9f57af2
Binary files /dev/null and b/DocumentService.Tests.Live/TestData/Pdfs/pdfWithSwissQRCode.pdf differ
diff --git a/DocumentService.Tests.Live/TestData/Pdfs/valid.pdf b/DocumentService.Tests.Live/TestData/Pdfs/valid.pdf
new file mode 100644
index 0000000..e8974ea
Binary files /dev/null and b/DocumentService.Tests.Live/TestData/Pdfs/valid.pdf differ