Render invoice-editor images (mfr portal only) into the PDF

The invoice line editor already shows <img> pictures pulled from the mfr
portal, but the PDF pipeline's HTML-to-MigraDoc converter had no <img>
handler, so they never appeared on the actual invoice.

InvoiceService now fetches only https://portal.mobilefieldreport.com/mfr/*
image sources before rendering (size/type/count capped, failures logged and
skipped) and hands the resolved images down through ApplyInvoice into the
HTML converter, which renders them inline at their original HTML position
and size (px converted to cm, capped against layout overflow). See ADR 0018.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 19:36:31 +02:00
co-authored by Claude Sonnet 5
parent 1a8a14791d
commit 071982caec
15 changed files with 516 additions and 28 deletions
+5 -5
View File
@@ -34,7 +34,7 @@ public class InvoiceDraftServiceTests
public FdsInvoiceData? PreviewInvoice;
public bool? PreviewDraft;
public Task<FdsInvoiceData> LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); }
public Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return Task.FromResult(new Document()); }
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException();
@@ -592,12 +592,12 @@ public class InvoiceDraftServiceTests
}
[Fact]
public void RenderPreview_SynthesizesDraftRegistrationFromSession()
public async Task RenderPreview_SynthesizesDraftRegistrationFromSession()
{
var (svc, inv, _) = NewService();
var s = svc.OpenFromPayload(Payload(), "user1");
var doc = svc.RenderPreview(s.Token);
var doc = await svc.RenderPreviewAsync(s.Token);
Assert.NotNull(doc);
Assert.True(inv.PreviewDraft); // always rendered as a draft
@@ -610,10 +610,10 @@ public class InvoiceDraftServiceTests
}
[Fact]
public void RenderPreview_UnknownToken_ReturnsNull()
public async Task RenderPreview_UnknownToken_ReturnsNull()
{
var (svc, _, _) = NewService();
Assert.Null(svc.RenderPreview("ghost"));
Assert.Null(await svc.RenderPreviewAsync("ghost"));
}
[Fact]
+160
View File
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.intranet;
using Fuchs.Notifications;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises <see cref="InvoiceService.ResolveInlineImagesAsync"/> — the whitelist-restricted image
/// fetch that lets invoice-editor picture links (mfr portal only) appear in the rendered PDF
/// (ADR 0018). Covers the whitelist boundary, fetch failure modes (each must just drop that one
/// image, never throw), and the per-invoice fetch cap.
/// </summary>
public class InvoiceInlineImageTests
{
private sealed class StubHandler : HttpMessageHandler
{
public readonly List<string> RequestedUrls = new();
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
RequestedUrls.Add(request.RequestUri!.ToString());
return Task.FromResult(_respond(request));
}
}
private sealed class StubFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static InvoiceService MakeService(HttpMessageHandler handler) => new(
new Fuchs_intranet(new ConfigurationBuilder().Build()),
Mock.Of<IPdfService>(),
Mock.Of<IBlobStorageService>(),
Mock.Of<IEventService>(),
Mock.Of<IERechnungService>(),
new StubFactory(handler),
NullLogger<InvoiceService>.Instance);
private static FdsInvoiceData InvoiceWithDesc(params string[] descs)
{
var items = new List<Dictionary<string, object>>();
foreach (var d in descs) items.Add(new Dictionary<string, object> { ["type"] = "material", ["desc"] = d });
return new FdsInvoiceData
{
Req = new List<Dictionary<string, object>> { new() { ["text"] = "Block", ["items"] = items } }
};
}
private static HttpResponseMessage ImageResponse(byte[] bytes) =>
new(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) { Headers = { ContentType = new("image/png") } } };
[Fact]
public async Task ResolveInlineImagesAsync_WhitelistedUrl_ReturnsBase64DataForOriginalSrc()
{
byte[] bytes = { 1, 2, 3, 4 };
var handler = new StubHandler(_ => ImageResponse(bytes));
string url = InvoiceService.AllowedInlineImagePrefix + "pic1.png";
var invoice = InvoiceWithDesc($"<p>Before</p><img src=\"{url}\"><p>After</p>");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.True(result.ContainsKey(url));
Assert.Equal("base64:" + Convert.ToBase64String(bytes), result[url]);
Assert.Single(handler.RequestedUrls);
}
[Theory]
[InlineData("https://evil.example.com/mfr/pic1.png")]
[InlineData("https://portal.mobilefieldreport.com.evil.com/mfr/pic1.png")]
[InlineData("http://portal.mobilefieldreport.com/mfr/pic1.png")]
[InlineData("https://portal.mobilefieldreport.com/other/pic1.png")]
public async Task ResolveInlineImagesAsync_NonWhitelistedSrc_NeverFetched(string url)
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
Assert.Empty(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_NoImages_SkipsHttpEntirely()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 }));
var invoice = InvoiceWithDesc("<p>Just text, no picture.</p>");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
Assert.Empty(handler.RequestedUrls);
}
[Fact]
public async Task ResolveInlineImagesAsync_HttpErrorStatus_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
string url = InvoiceService.AllowedInlineImagePrefix + "missing.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_NonImageContentType_DropsImage()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2 }) { Headers = { ContentType = new("text/html") } }
});
string url = InvoiceService.AllowedInlineImagePrefix + "notreally.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_ThrowingHandler_DropsImageWithoutThrowing()
{
var handler = new StubHandler(_ => throw new HttpRequestException("network down"));
string url = InvoiceService.AllowedInlineImagePrefix + "boom.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Empty(result);
}
[Fact]
public async Task ResolveInlineImagesAsync_DuplicateSrcAcrossLines_FetchedOnce()
{
var handler = new StubHandler(_ => ImageResponse(new byte[] { 9 }));
string url = InvoiceService.AllowedInlineImagePrefix + "shared.png";
var invoice = InvoiceWithDesc($"<img src=\"{url}\">", $"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.Single(handler.RequestedUrls);
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Collections.Generic;
using System.Linq;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Shapes;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Extensions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the HTML→MigraDoc converter's &lt;img&gt; handling (added alongside the invoice-text
/// image feature, ADR 0018): only pre-resolved sources render, they land inline at the position the
/// HTML puts them (not appended/floating), and their size mirrors the HTML width/height, capped
/// against layout-breaking values.
/// </summary>
public class MigraDocHtmlImageTests
{
private const string DataSrc = "base64:AAAA";
private static Cell NewCell()
{
var doc = new Document();
var sec = doc.AddSection();
var table = sec.AddTable();
table.AddColumn();
var row = table.AddRow();
return row.Cells[0];
}
[Fact]
public void AddHtml_UnresolvedImgSrc_IsDropped()
{
var cell = NewCell();
cell.AddHtml("<div>Text <img src=\"https://not-fetched.example/x.png\"> more</div>", null);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
Assert.DoesNotContain(para.Elements.Cast<DocumentObject>(), e => e is Image);
}
[Fact]
public void AddHtml_ResolvedImgSrc_RendersInlineAtHtmlPosition()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div>Before <img src=\"x\"> After</div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var kinds = para.Elements.Cast<DocumentObject>().Select(e => e.GetType().Name).ToList();
int imgIdx = kinds.IndexOf(nameof(Image));
Assert.NotEqual(-1, imgIdx);
// The image must sit between the "Before " and " After" text runs, not appended after them.
Assert.True(imgIdx > 0 && imgIdx < kinds.Count - 1,
$"expected image between surrounding text, got element order: {string.Join(",", kinds)}");
}
[Fact]
public void AddHtml_WidthAndHeightAttributes_SetExplicitSizeFromPx()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" width=\"96\" height=\"48\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
// 96px / 48px at 96dpi → 2.54cm / 1.27cm.
Assert.Equal(2.54, img.Width.Centimeter, 2);
Assert.Equal(1.27, img.Height.Centimeter, 2);
}
[Fact]
public void AddHtml_StyleWidthHeight_SetExplicitSizeFromPx()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" style=\"width: 192px; height: 96px;\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.Equal(5.08, img.Width.Centimeter, 2);
Assert.Equal(2.54, img.Height.Centimeter, 2);
}
[Fact]
public void AddHtml_HugeHtmlSize_IsCappedToLayoutSafeMaximum()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
// A huge (or hand-edited/malformed) size must never blow out the invoice table cell.
cell.AddHtml("<div><img src=\"x\" width=\"4000\" height=\"4000\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.True(img.Width.Centimeter <= 7.0);
Assert.True(img.Height.Centimeter <= 10.0);
}
[Fact]
public void AddHtml_NoSizeGiven_KeepsAspectRatioLocked()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.True(img.LockAspectRatio);
}
}