fix: invoice/reminder PDF preview 404s and their unlogged root causes

An invoice's finalisation-time PDF render/store (fds__setInvoiceFile) can
fail silently; the row stays finalised with [file] = NULL and inv/rdoc
(the invoice list's preview icon) 404'd forever with no trace in any log.

- inv/rdoc and rem/idoc now regenerate and persist a missing document on
  read, mirroring the legacy VB "idoc" fallback (Fuchs_Database ADR 0025),
  gated to finalised + non-external invoices only — a draft render must
  never be persisted, since fds__setInvoiceFile/fds__setReminderFile only
  write WHERE [file] IS NULL and finalisation would then silently no-op.
- StoreInvoiceDocumentFileAsync/StoreReminderDocumentFileAsync now log
  every distinct failure (render threw, render produced 0 bytes, DB write
  threw, DB write matched 0 rows) instead of collapsing them into a single
  silent empty-array return. The 0-rows case needed switching from
  setSQLValue_async (reports success once the command completes, whatever
  the row count) to setSQLValue_async_result, whose AffectedRows makes a
  no-op UPDATE distinguishable from an actual write for the first time.
- HandleRequestIdoc/HandleReminderIdoc no longer treat
  StoreInvoiceDocumentFileAsync's Array.Empty<byte>() failure signal as
  success (a bare `ct == null` check never caught it), which would have
  served a 0-byte "PDF".
- rdoc/HandleRequestIdoc reject a missing 'id' form field instead of doing
  wasted work with an empty id.
- _BackupAndClearInvoiceFile.sql is annotated as a manual-only maintenance
  utility, never called from app code, so it's clear why running it leaves
  no application log trail.
- Client-side: $inv.pdfPreviewTimeout was 120 (ms) instead of 120000 — the
  preview dialog was timing out client-side after 120ms on every call.
- Fixed a genuine process-wide test flake: PdfPipelineTests and
  ERechnungMapperTests both install PdfSharp's global font resolver, and
  whichever ran first would win, making the other's tests fail
  deterministically once eRechnungLib's SystemFontResolver got there
  first. FuchsPdf.EnsureFontResolver is now also race-safe under
  concurrent first use (a check-then-set without a lock). Also removed
  the InvoiceService.PublishAsync failure logging added mid-session after
  code review found it duplicated every call site's own, more detailed
  logging against ADR 0024's single-write-path rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Stefan
2026-09-16 21:07:24 +02:00
co-authored by Claude Sonnet 5
parent b5613bee0d
commit 99bf5337f8
18 changed files with 373 additions and 38 deletions
+27
View File
@@ -0,0 +1,27 @@
using System.Runtime.CompilerServices;
namespace Fuchs.Tests;
/// <summary>
/// Installs PdfSharp's global font resolver once, before any test runs.
/// </summary>
/// <remarks>
/// <see cref="PdfSharp.Fonts.GlobalFontSettings.FontResolver"/> is process-wide and, once used,
/// cannot be swapped — PdfSharp throws "You must not change font resolver after it was once used".
/// Two independent installers exist in this solution: <c>FuchsPdf.EnsureFontResolver</c> (installs
/// <c>OCOREFontResolver</c>, used by <see cref="PdfPipelineTests"/>) and eRechnungLib's
/// <c>SystemFontResolver.EnsureInstalled</c> (installs itself only when none is set yet, used
/// internally by the parameterless <c>ToZugferd(profile)</c> overload some tests call, e.g.
/// <see cref="ERechnungMapperTests"/>). Production code never hits that second path — it always
/// supplies its own rendered PDF (ADR 0012) — so this conflict is test-process-only, but whichever
/// installer's first render happened to run first (an xUnit test-execution-order accident, not a
/// property of the code under test) would win for every other test in the process. Installing
/// OCOREFontResolver here, before test discovery hands control to any test method, makes the
/// outcome deterministic instead of order-dependent.
/// </remarks>
internal static class FontResolverTestBootstrap
{
[ModuleInitializer]
internal static void Install() =>
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
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;
using MigraDoc.DocumentObjectModel;
using Moq;
using OCORE.security;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Exercises the failure branches of <see cref="InvoiceService.StoreInvoiceDocumentFileAsync"/>.
/// The method signals every failure with an empty array, so the three distinct causes — render
/// threw, render produced no bytes, DB store failed — are only distinguishable by what gets
/// logged. Each must be logged individually, and none may reach the blob upload or the
/// InvoiceFileCreated event. No SQL Server: the store-failure case uses an unconfigured
/// connection so the write fails fast inside the OCORE helper.
/// </summary>
public class InvoiceServiceStoreFileTests
{
private sealed class RecordingLogger : ILogger<InvoiceService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
}
private sealed class NoHttp : IHttpClientFactory
{
public HttpClient CreateClient(string name) => throw new InvalidOperationException("no HTTP expected");
}
// A refused local port: the fds__setInvoiceFile write fails inside the OCORE helper's own
// try/catch (returning false) instead of hanging on a connect timeout.
private const string UnreachableConnectionString =
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
private static (InvoiceService svc, RecordingLogger log, Mock<IBlobStorageService> blob, Mock<IEventService> events)
MakeService(Mock<IPdfService> pdf, string? connectionString = null)
{
var log = new RecordingLogger();
var blob = new Mock<IBlobStorageService>();
var events = new Mock<IEventService>();
var erechnung = new Mock<IERechnungService>();
erechnung.SetupGet(e => e.Enabled).Returns(false);
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
}).Build();
var svc = new InvoiceService(
new Fuchs_intranet(config),
pdf.Object, blob.Object, events.Object, erechnung.Object,
new NoHttp(), config, log);
return (svc, log, blob, events);
}
private static FdsInvoiceData Invoice(bool isFinal) => new()
{
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
{
["Id"] = "W7VAL",
["InvoiceId"] = "R2026-0258",
["IsFinal"] = isFinal
}),
IsDraft = !isFinal
};
private static Mock<IPdfService> PdfReturning(byte[] bytes)
{
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<Document>())).Returns(bytes);
return pdf;
}
private static void AssertNothingPersisted(Mock<IBlobStorageService> blob, Mock<IEventService> events)
{
blob.Verify(b => b.UploadInvoicePdfAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<byte[]>(),
It.IsAny<IReadOnlyDictionary<string, object?>>(), It.IsAny<CancellationToken>()), Times.Never);
events.Verify(e => e.InvoiceFileCreatedAsync(It.IsAny<FdsInvoiceData>(), It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task StoreInvoiceDocumentFileAsync_RenderProducesZeroBytes_ReturnsEmptyAndLogsError(bool draft)
{
var (svc, log, blob, events) = MakeService(PdfReturning(Array.Empty<byte>()));
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: !draft), draft, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Null(entry.Exception);
Assert.Contains("produced 0 bytes", entry.Message);
Assert.Contains("W7VAL", entry.Message);
Assert.Contains($"draft={draft}", entry.Message);
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderThrows_ReturnsEmptyAndLogsExceptionNotZeroBytes()
{
var pdf = new Mock<IPdfService>();
var boom = new InvalidOperationException("font resolver exploded");
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>())).ThrowsAsync(boom);
var (svc, log, blob, events) = MakeService(pdf);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
var entry = Assert.Single(log.Entries, e => e.Level == LogLevel.Error);
Assert.Same(boom, entry.Exception);
Assert.Contains("render failed", entry.Message);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
AssertNothingPersisted(blob, events);
}
[Fact]
public async Task StoreInvoiceDocumentFileAsync_RenderOkButDbStoreFails_ReturnsEmptyWithoutRenderErrorAndSkipsBlob()
{
// Render succeeds; the fds__setInvoiceFile write fails (no connection configured). The
// SQL error is reported through FIS_SQLOptions.OnError, not through the render branches —
// so neither render message may appear, and the PDF must not be archived to blob storage
// as if the primary DB write had succeeded.
var (svc, log, blob, events) = MakeService(PdfReturning(new byte[] { 0x25, 0x50, 0x44, 0x46 }), UnreachableConnectionString);
var result = await svc.StoreInvoiceDocumentFileAsync(Invoice(isFinal: true), false, "user1", new DatabaseSecurity("user1", null));
Assert.Empty(result);
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("produced 0 bytes"));
Assert.DoesNotContain(log.Entries, e => e.Message.Contains("render failed"));
AssertNothingPersisted(blob, events);
}
}
+5 -14
View File
@@ -11,7 +11,6 @@ using eRechnungLib.Profiles;
using Fuchs.intranet;
using Fuchs.Services;
using MigraDoc.DocumentObjectModel;
using MigraDoc.Rendering;
using Microsoft.Extensions.Configuration;
using PdfSharp.Pdf.IO;
using Xunit;
@@ -31,14 +30,6 @@ public class PdfPipelineTests
// ── Stage 1 helper: a "dummy" visual invoice PDF built purely with PdfSharp/MigraDoc ──
private static byte[] BuildDummyPdfWithPdfSharp()
{
// Same font resolver the production render path installs (PdfSharp 6 no longer
// resolves system fonts on its own).
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver is null ||
PdfSharp.Fonts.GlobalFontSettings.FontResolver.GetType() != typeof(OCORE_web_pdf.pdf.OCOREFontResolver))
{
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new OCORE_web_pdf.pdf.OCOREFontResolver();
}
var doc = new Document();
doc.Info.Title = "Dummy Rechnung";
var normal = doc.Styles["Normal"]!;
@@ -50,11 +41,11 @@ public class PdfPipelineTests
sec.AddParagraph("Position 1: Beratungsleistung — 200,00 EUR netto");
sec.AddParagraph("Position 2: Entwicklung — 500,00 EUR netto");
var renderer = new PdfDocumentRenderer { Document = doc };
renderer.RenderDocument();
using var ms = new MemoryStream();
renderer.PdfDocument.Save(ms, closeStream: false);
return ms.ToArray();
// Goes through FuchsPdf.DocToPdfBytesRaw so the font resolver is installed exactly the way
// (and exactly once, thread-safely) the production render path installs it — a second,
// separate check-then-set here previously raced PdfSharp's "must not change font resolver
// after it was once used" guard under parallel test execution.
return FuchsPdf.DocToPdfBytesRaw(doc);
}
// ── Stage 3 helper: a minimal but EN 16931-complete domestic invoice model ──