feat: Enhance error logging for PDF preview failures and SQL timeouts

- Implemented a new method `ShapeTableNames` to gather entity and navigation table names, ensuring no blanks or duplicates are included.
- Updated `FdsMfrClient` to handle cases where schema queries return no tables, logging appropriate warnings and preventing misleading errors.
- Introduced `PdfPreviewAsync` in `IntranetController` to manage PDF preview rendering, capturing detailed logs for failures and notifying users with readable error messages.
- Added comprehensive unit tests for error logging and table name shaping to ensure robustness and correctness.
- Documented decision to persist user action failures to the `fds__admin_logdebug` table, enhancing support diagnostics.
This commit is contained in:
Stefan
2026-09-16 16:35:04 +02:00
parent 38e0d55b09
commit fce83c528f
19 changed files with 609 additions and 56 deletions
+209
View File
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Data;
using Fuchs.Controllers;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using Moq;
using Newtonsoft.Json;
using Xunit;
using static fds.FdsMfrClient;
namespace Fuchs.Tests;
/// <summary>
/// Covers the error-attribution fixes made after the 2026-09-16 production log review: every
/// "SQL Error in getSQLDataSet" row in fds__admin_logdebug carried Data=NULL (the failing SQL
/// was dropped), unhandled Do() exceptions and PDF-preview failures never reached the DB log at
/// all, and the browser alert always had an empty description. The DB write itself (debug_log →
/// fds__admin_logdebug) is SQL-bound and not unit-tested; these tests pin what gets handed to it.
/// </summary>
public class ErrorLoggingTests
{
// ── FIS_SQLOptions ─────────────────────────────────────────────────────────
[Fact]
public void FisSqlOptions_OnError_ForwardsSqlStatementAndContextToDbLog()
{
string? codeRef = null; Exception? loggedEx = null; object? loggedData = null;
var logger = new Mock<ILogger>();
var context = new Dictionary<string, object> { ["fn"] = "inv", ["id"] = "invl", ["code"] = "" };
var opt = new FIS_SQLOptions(context, logger.Object, (p, ex, data) => { codeRef = p; loggedEx = ex; loggedData = data; });
var ex = new TimeoutException("Das Ausführungstimeout ist abgelaufen.");
opt.OnError!("getSQLDataSet", ex, new { sql = "EXECUTE [dbo].[fds__getInvoiceList] @authuser;" });
Assert.Equal("SQL Error in getSQLDataSet", codeRef);
Assert.Same(ex, loggedEx);
// The DB column is the JSON of this object — it must carry the SQL, which was the missing piece.
string json = JsonConvert.SerializeObject(loggedData);
Assert.Contains("fds__getInvoiceList", json);
Assert.Contains("\"fn\":\"inv\"", json);
Assert.Contains("\"id\":\"invl\"", json);
logger.Verify(l => l.Log(LogLevel.Error, It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(), ex,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Once);
}
[Fact]
public void FisSqlOptions_OnError_DbLogThrows_DoesNotPropagate()
{
var opt = new FIS_SQLOptions(null, null, (_, _, _) => throw new InvalidOperationException("no db"));
var recorded = Record.Exception(() => opt.OnError!("setSQLValue_async", new Exception("x"), new { sql = "SELECT 1;" }));
Assert.Null(recorded);
}
[Fact]
public void FisSqlOptions_BuildLogData_UnwrapsOcoreSqlPayload()
{
var d = FIS_SQLOptions.BuildLogData(null, new { sql = "SELECT 1;" });
Assert.Equal("SELECT 1;", d["sql"]);
Assert.False(d.ContainsKey("context"));
}
[Fact]
public void FisSqlOptions_BuildLogData_KeepsForeignPayloadVerbatim()
{
var payload = new { statement = "SELECT 2;" };
var d = FIS_SQLOptions.BuildLogData(null, payload);
Assert.Same(payload, d["sql"]);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void FisSqlOptions_BuildLogData_NullOrEmptyContextIsOmitted(bool empty)
{
var ctx = empty ? new Dictionary<string, object>() : null;
var d = FIS_SQLOptions.BuildLogData(ctx, new { sql = "SELECT 1;" });
Assert.False(d.ContainsKey("context"));
}
[Fact]
public void FisSqlOptions_BuildLogData_NullData_ProducesEmptyOrContextOnly()
{
Assert.Empty(FIS_SQLOptions.BuildLogData(null, null));
var ctx = new Dictionary<string, object> { ["fn"] = "rem" };
var d = FIS_SQLOptions.BuildLogData(ctx, null);
Assert.Single(d);
Assert.Same(ctx, d["context"]);
}
// ── Do() catch-all + response description header ──────────────────────────
[Fact]
public void BuildDoErrorLogData_ContainsRouteUserAndFormKeys_ButNoFormValues()
{
var d = IntranetController.BuildDoErrorLogData("rem", "dpreview", "", "user-1", new[] { "token" });
string json = JsonConvert.SerializeObject(d);
Assert.Contains("\"fn\":\"rem\"", json);
Assert.Contains("\"id\":\"dpreview\"", json);
Assert.Contains("\"user\":\"user-1\"", json);
Assert.Contains("\"formKeys\":[\"token\"]", json);
}
[Theory]
[InlineData("Die PDF-Vorschau konnte nicht erstellt werden.", "Die%20PDF-Vorschau%20konnte%20nicht%20erstellt%20werden.")]
[InlineData("a+b", "a%2Bb")] // '+' must not survive: the client maps '+' to a space
[InlineData("Zeile1\r\nZeile2", "Zeile1%20%20Zeile2")]
[InlineData(" trimmed ", "trimmed")]
public void EncodeResponseDescription_ProducesAsciiHeaderValue_ClientDecodesToOriginal(string msg, string expected)
{
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.Equal(expected, encoded);
Assert.All(encoded, c => Assert.True(c < 128, "header must be ASCII"));
// Mirror of the client: decodeURIComponent(v.replace(/\+/g, '%20'))
Assert.Equal(msg.Replace("\r", " ").Replace("\n", " ").Trim(), Uri.UnescapeDataString(encoded.Replace("+", "%20")));
}
[Fact]
public void EncodeResponseDescription_UmlautsSurviveRoundTrip()
{
const string msg = "Zeitüberschreitung – bitte erneut versuchen";
string encoded = IntranetController.EncodeResponseDescription(msg);
Assert.All(encoded, c => Assert.True(c < 128));
Assert.Equal(msg, Uri.UnescapeDataString(encoded));
}
// ── PDF preview failure payloads ──────────────────────────────────────────
[Fact]
public void PdfPreviewFailureMessage_UsesInnermostExceptionText()
{
var ex = new InvalidOperationException("outer",
new AggregateException("agg", new ApplicationException("Font 'Courier New' cannot be resolved")));
string msg = IntranetController.PdfPreviewFailureMessage(ex);
Assert.StartsWith("Die PDF-Vorschau konnte nicht erstellt werden: ", msg);
Assert.EndsWith("Font 'Courier New' cannot be resolved", msg);
Assert.DoesNotContain("outer", msg);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void PdfPreviewFailureMessage_EmptyExceptionText_FallsBackToGenericText(string text)
{
string msg = IntranetController.PdfPreviewFailureMessage(new Exception(text));
Assert.Equal("Die PDF-Vorschau konnte nicht erstellt werden.", msg);
}
[Fact]
public void BuildPdfPreviewLogData_CarriesEndpointRefUserAndDuration()
{
var d = IntranetController.BuildPdfPreviewLogData("rem/dpreview", "tok-123", "user-1", 4321);
string json = JsonConvert.SerializeObject(d);
Assert.Contains("\"what\":\"rem/dpreview\"", json);
Assert.Contains("\"ref\":\"tok-123\"", json);
Assert.Contains("\"user\":\"user-1\"", json);
Assert.Contains("\"ms\":4321", json);
}
// ── Data service: schema failure must not cascade into "FROM [dbo].[]" ────
private static DataRowCollection NavTables(params string[] names)
{
var t = new DataTable();
t.Columns.Add("tablename", typeof(string));
foreach (var n in names) t.Rows.Add(n);
return t.Rows;
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ShapeTableNames_NoEntityTable_ReturnsEmpty_EvenWithNavTables(string entityTable)
{
var names = DatabaseSchema.ShapeTableNames(entityTable, NavTables("mfr_invoice_items"));
Assert.Empty(names);
}
[Fact]
public void ShapeTableNames_EntityTableFirst_ThenNavTables_SkippingBlanksAndDuplicates()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables("mfr_invoice_items", "", "mfr_invoice_items", "mfr_invoice_files"));
Assert.Equal(new[] { "mfr_invoice", "mfr_invoice_items", "mfr_invoice_files" }, names);
}
[Fact]
public void ShapeTableNames_NoNavTables_ReturnsEntityOnly()
{
var names = DatabaseSchema.ShapeTableNames("mfr_invoice", NavTables());
Assert.Equal(new[] { "mfr_invoice" }, names);
}
}