Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
2026-07-08 19:33:23 +02:00
parent 4abf81cd7d
commit 59a2b86c09
23 changed files with 676 additions and 60 deletions
+121
View File
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Notifications;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Tests for <see cref="EventService"/>, the single point every server-side
/// operation goes through to notify the user. Covers the two failure methods the
/// exception safety nets rely on (<c>UserIssueAsync</c> from
/// <c>IntranetController.Do</c>'s catch-all, and <c>InvoiceIssueAsync</c> from
/// <c>HandleInvoiceGet</c>) rendering as <c>"error"</c> notifications, plus a
/// contrasting success path rendering as <c>"info"</c> — see ADR 0003.
/// </summary>
public class EventServiceTests
{
// ── Test doubles: capture the GuiNotification pushed to Clients.All ─────────
private sealed class CapturingClientProxy : IClientProxy
{
public string? Method { get; private set; }
public object?[]? Args { get; private set; }
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Method = method;
Args = args;
return Task.CompletedTask;
}
}
private sealed class StubHubClients : IHubClients
{
private readonly IClientProxy _all;
public StubHubClients(IClientProxy all) => _all = all;
public IClientProxy All => _all;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
public IClientProxy Client(string connectionId) => throw new System.NotImplementedException();
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new System.NotImplementedException();
public IClientProxy Group(string groupName) => throw new System.NotImplementedException();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new System.NotImplementedException();
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new System.NotImplementedException();
public IClientProxy User(string userId) => throw new System.NotImplementedException();
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new System.NotImplementedException();
}
private sealed class StubHubContext : IHubContext<NotificationHub>
{
public StubHubContext(IHubClients clients) => Clients = clients;
public IHubClients Clients { get; }
public IGroupManager Groups => throw new System.NotImplementedException();
}
private static (EventService svc, CapturingClientProxy proxy) CreateService()
{
var proxy = new CapturingClientProxy();
var hub = new StubHubContext(new StubHubClients(proxy));
return (new EventService(hub, NullLogger<EventService>.Instance), proxy);
}
private static GuiNotification Captured(CapturingClientProxy proxy)
{
Assert.Equal("notification", proxy.Method);
Assert.NotNull(proxy.Args);
var arg = Assert.Single(proxy.Args!);
return Assert.IsType<GuiNotification>(arg);
}
// ── Failure paths the exception safety nets use ─────────────────────────────
[Fact]
public async Task UserIssueAsync_PublishesErrorNotificationWithMessage()
{
var (svc, proxy) = CreateService();
await svc.UserIssueAsync(
"Aktion fehlgeschlagen",
"Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
"user-42",
new Dictionary<string, object?> { ["fn"] = "inv" });
var n = Captured(proxy);
Assert.Equal("error", n.Severity);
Assert.Equal("Aktion fehlgeschlagen", n.Title);
Assert.Equal(DomainEventType.UserIssue.ToString(), n.Type);
Assert.Contains("nicht abgeschlossen werden", n.Message);
Assert.Equal("inv", n.Context["fn"]);
}
[Fact]
public async Task InvoiceIssueAsync_PublishesErrorNotificationCarryingInvoiceId()
{
var (svc, proxy) = CreateService();
await svc.InvoiceIssueAsync(
"Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.",
"user-42",
"INV-1001");
var n = Captured(proxy);
Assert.Equal("error", n.Severity);
Assert.Equal(DomainEventType.InvoiceCreationFailed.ToString(), n.Type);
Assert.Equal("Die Rechnung konnte aufgrund eines Fehlers nicht geladen werden.", n.Message);
Assert.Equal("INV-1001", n.Context["id"]);
}
// ── Contrasting success path renders as info, not error ─────────────────────
[Fact]
public async Task InvoiceMarkedSentAsync_PublishesInfoNotification()
{
var (svc, proxy) = CreateService();
await svc.InvoiceMarkedSentAsync("INV-1001", "R2026-0001", "user-42");
var n = Captured(proxy);
Assert.Equal("info", n.Severity);
Assert.Contains("R2026-0001", n.Message);
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Collections.Generic;
using Fuchs.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Covers <see cref="RequestValueHelper.Resolve"/>, which backs IntranetController's
/// Form()/HasForm() helpers. Endpoints in _allowedGet (e.g. req/idoc, rem/idoc) are invoked via
/// a plain GET (window.open with '?id=...'), so this must resolve from the query string without
/// ever touching an IFormCollection built from a non-form request (that would previously throw
/// InvalidOperationException in production - see Do() unhandled-exception log for fn=req id=idoc).
/// </summary>
public class RequestValueHelperTests
{
private static IFormCollection Form(params (string Key, string Value)[] pairs)
{
var dict = new Dictionary<string, StringValues>();
foreach (var (key, value) in pairs) dict[key] = value;
return new FormCollection(dict);
}
private static IQueryCollection Query(params (string Key, string Value)[] pairs)
{
var dict = new Dictionary<string, StringValues>();
foreach (var (key, value) in pairs) dict[key] = value;
return new QueryCollection(dict);
}
[Fact]
public void Resolve_FormContentTypeWithKey_ReturnsFormValue()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: true,
form: Form(("id", "abc123")),
query: Query(("id", "from-query")),
key: "id");
Assert.Equal("abc123", result);
}
[Fact]
public void Resolve_NoFormContentType_FallsBackToQuery()
{
// Simulates a GET request opened via window.open('?id=...'): no Content-Type header,
// so the (empty) form collection must not be consulted - only the query string.
string? result = RequestValueHelper.Resolve(
hasFormContentType: false,
form: FormCollection.Empty,
query: Query(("id", "7O32P")),
key: "id");
Assert.Equal("7O32P", result);
}
[Fact]
public void Resolve_FormContentTypeButKeyMissingFromForm_FallsBackToQuery()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: true,
form: Form(("other", "value")),
query: Query(("id", "7O32P")),
key: "id");
Assert.Equal("7O32P", result);
}
[Fact]
public void Resolve_KeyMissingFromBoth_ReturnsNull()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: true,
form: Form(("other", "value")),
query: Query(("other", "value")),
key: "id");
Assert.Null(result);
}
[Fact]
public void Resolve_NoFormContentTypeAndQueryEmpty_ReturnsNull()
{
string? result = RequestValueHelper.Resolve(
hasFormContentType: false,
form: FormCollection.Empty,
query: QueryCollection.Empty,
key: "id");
Assert.Null(result);
}
}