Refactor code structure for improved readability and maintainability
Playwright Tests / test (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user