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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user