Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt, welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen. Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json ueberschneiden und darum gemeinsam abgelegt werden: Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig): - Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert - DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli - WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt (version, os, checks, metrics, status stopped) - Security: MasterKeyResolver, SecretRedactor, FilePermissions - Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin) - deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben ueber .gitignore aussen vor - setup.json fuer die Erstinstallation - UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md verweist auf den neuen Plan Einfenster-Shell (UI-Redesign): - ShellWindow + ShellNavModel als Seitenleisten-Shell - WindowMenuBar und WindowMenuModel entfallen - Modul- und Kernfenster auf die Shell-Einbettung angepasst Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
547 lines
22 KiB
C#
547 lines
22 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Net;
|
||
using System.Net.Http;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using PolyTraderSharp.Models;
|
||
using PolyTraderSharp.Services;
|
||
using Xunit;
|
||
|
||
namespace PolyTrader.Tests
|
||
{
|
||
/// <summary>
|
||
/// Sicherheitsnetz für den Watchdog-Heartbeat (B.1). Die Tests fixieren die drei Eigenschaften,
|
||
/// die im Review der Fremdprojekte aufgefallen sind:
|
||
/// 1. TLS-Zwang (der mitgelieferte Watchdog-Testclient deaktiviert die Zertifikatsprüfung),
|
||
/// 2. Feldname "interval" – die Watchdog-API ignoriert "intervalSeconds" still,
|
||
/// 3. ein nicht erreichbarer Watchdog darf niemals eine Ausnahme nach außen geben.
|
||
/// </summary>
|
||
public class WatchdogHeartbeatTests
|
||
{
|
||
// ----- TLS-Zwang / URL-Aufbau -----
|
||
|
||
[Theory]
|
||
[InlineData("https://watchdog.example.de")]
|
||
[InlineData("https://watchdog.example.de/")]
|
||
[InlineData("http://localhost:8080")]
|
||
[InlineData("http://127.0.0.1")]
|
||
public void TryBuildEndpoint_accepts_https_and_local_http(string baseUrl)
|
||
{
|
||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, WatchdogHeartbeatService.PingPath, out Uri? uri, out string problem);
|
||
|
||
Assert.True(ok, problem);
|
||
Assert.NotNull(uri);
|
||
Assert.EndsWith("/api/watchdog/v1/ping", uri!.AbsoluteUri);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryBuildEndpoint_rejects_plain_http_to_remote_host()
|
||
{
|
||
// Über http:// ginge der Agent-Token im Klartext über das Netz.
|
||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
|
||
"http://watchdog.example.de", WatchdogHeartbeatService.PingPath, out Uri? uri, out string problem);
|
||
|
||
Assert.False(ok);
|
||
Assert.Null(uri);
|
||
Assert.Contains("HTTPS", problem);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("")]
|
||
[InlineData(" ")]
|
||
[InlineData("watchdog.example.de")] // ohne Schema
|
||
[InlineData("ftp://watchdog.example.de")]
|
||
public void TryBuildEndpoint_rejects_invalid_urls(string baseUrl)
|
||
{
|
||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, WatchdogHeartbeatService.PingPath, out _, out string problem);
|
||
|
||
Assert.False(ok);
|
||
Assert.NotEmpty(problem);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryBuildEndpoint_keeps_base_path_of_subdirectory_installs()
|
||
{
|
||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
|
||
"https://example.de/watchdog", WatchdogHeartbeatService.PingPath, out Uri? uri, out _);
|
||
|
||
Assert.True(ok);
|
||
Assert.Equal("https://example.de/watchdog/api/watchdog/v1/ping", uri!.AbsoluteUri);
|
||
}
|
||
|
||
// ----- Konfigurationsprüfung -----
|
||
|
||
[Fact]
|
||
public void GetConfigurationProblem_reports_missing_url_token_and_source()
|
||
{
|
||
Assert.Contains("URL", Problem(s => s.WatchdogUrl = ""));
|
||
Assert.Contains("Token", Problem(s =>
|
||
{
|
||
s.WatchdogUrl = "https://wd.example.de";
|
||
s.WatchdogToken = "";
|
||
}));
|
||
Assert.Contains("Source", Problem(s =>
|
||
{
|
||
s.WatchdogUrl = "https://wd.example.de";
|
||
s.WatchdogToken = "tok";
|
||
s.WatchdogSource = "";
|
||
}));
|
||
}
|
||
|
||
[Fact]
|
||
public void GetConfigurationProblem_returns_null_when_ready()
|
||
{
|
||
var service = Build(out _, Valid());
|
||
Assert.Null(service.GetConfigurationProblem());
|
||
}
|
||
|
||
// ----- Payload -----
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_posts_expected_payload_and_auth_header()
|
||
{
|
||
var settings = Valid();
|
||
settings.WatchdogSource = "polytrader";
|
||
settings.WatchdogInstance = "prod-1";
|
||
settings.WatchdogIntervalSeconds = 45;
|
||
|
||
var service = Build(out FakeHandler handler, settings);
|
||
|
||
var result = await service.SendHeartbeatAsync("ok", "Testlauf");
|
||
|
||
Assert.True(result.Success, result.Detail);
|
||
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
|
||
Assert.Equal("https://wd.example.de/api/watchdog/v1/ping", handler.LastRequest.RequestUri!.AbsoluteUri);
|
||
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||
Assert.Equal("secret-token", handler.LastRequest.Headers.Authorization!.Parameter);
|
||
Assert.False(handler.LastRequest.Headers.Contains("X-Watchdog-Key"));
|
||
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
var root = doc.RootElement;
|
||
Assert.Equal("polytrader", root.GetProperty("source").GetString());
|
||
Assert.Equal("prod-1", root.GetProperty("instance").GetString());
|
||
Assert.Equal("heartbeat", root.GetProperty("type").GetString());
|
||
Assert.Equal("ok", root.GetProperty("status").GetString());
|
||
Assert.Equal("Testlauf", root.GetProperty("message").GetString());
|
||
|
||
// Entscheidend: die Watchdog-API liest "interval" und ignoriert "intervalSeconds" still.
|
||
Assert.Equal(45, root.GetProperty("interval").GetInt32());
|
||
Assert.False(root.TryGetProperty("intervalSeconds", out _));
|
||
|
||
// D-1: Version aus BuildInfo (Directory.Build.props), nicht mehr aus der Assembly-Version.
|
||
Assert.Equal(PolyTrader.Core.BuildInfo.Version, root.GetProperty("version").GetString());
|
||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("os").GetString()));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_includes_registered_checks()
|
||
{
|
||
var service = Build(out FakeHandler handler, Valid());
|
||
service.RegisterCheck("db", () => (true, null));
|
||
service.RegisterCheck("market_feed", () => (false, "Letzter Tick vor 14 min"));
|
||
|
||
await service.SendHeartbeatAsync();
|
||
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
var checks = doc.RootElement.GetProperty("checks");
|
||
Assert.True(checks.GetProperty("db").GetProperty("ok").GetBoolean());
|
||
Assert.False(checks.GetProperty("market_feed").GetProperty("ok").GetBoolean());
|
||
Assert.Equal("Letzter Tick vor 14 min", checks.GetProperty("market_feed").GetProperty("message").GetString());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_omits_checks_when_none_registered()
|
||
{
|
||
var service = Build(out FakeHandler handler, Valid());
|
||
await service.SendHeartbeatAsync();
|
||
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
Assert.True(doc.RootElement.TryGetProperty("checks", out var checksProp));
|
||
Assert.Equal(JsonValueKind.Null, checksProp.ValueKind);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_check_failure_reports_itself_instead_of_throwing()
|
||
{
|
||
var service = Build(out FakeHandler handler, Valid());
|
||
service.RegisterCheck("boom", () => throw new InvalidOperationException("kaputt"));
|
||
|
||
var result = await service.SendHeartbeatAsync();
|
||
|
||
Assert.True(result.Success, result.Detail); // die werfende Prüfung darf den Versand nicht verhindern
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
var boom = doc.RootElement.GetProperty("checks").GetProperty("boom");
|
||
Assert.False(boom.GetProperty("ok").GetBoolean());
|
||
Assert.Contains("kaputt", boom.GetProperty("message").GetString());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task UnregisterCheck_removes_a_previously_registered_check()
|
||
{
|
||
var service = Build(out FakeHandler handler, Valid());
|
||
service.RegisterCheck("temp", () => (true, null));
|
||
service.UnregisterCheck("temp");
|
||
|
||
await service.SendHeartbeatAsync();
|
||
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("checks").ValueKind);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_clamps_interval_to_minimum()
|
||
{
|
||
var settings = Valid();
|
||
settings.WatchdogIntervalSeconds = 1; // absurd niedrig -> würde den Watchdog fluten
|
||
|
||
var service = Build(out FakeHandler handler, settings);
|
||
await service.SendHeartbeatAsync();
|
||
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
Assert.Equal(10, doc.RootElement.GetProperty("interval").GetInt32());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_falls_back_to_default_instance_when_blank()
|
||
{
|
||
var settings = Valid();
|
||
settings.WatchdogInstance = " ";
|
||
|
||
var service = Build(out FakeHandler handler, settings);
|
||
await service.SendHeartbeatAsync();
|
||
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
Assert.Equal("default", doc.RootElement.GetProperty("instance").GetString());
|
||
}
|
||
|
||
// ----- Robustheit: ein toter Watchdog darf PolyTrader nicht beeinträchtigen -----
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_returns_failure_instead_of_throwing_on_network_error()
|
||
{
|
||
var service = Build(out _, Valid(), _ => throw new HttpRequestException("Kein DNS"));
|
||
|
||
var result = await service.SendHeartbeatAsync();
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Contains("Kein DNS", result.Detail);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_reports_http_error_status_as_failure()
|
||
{
|
||
var service = Build(out _, Valid(),
|
||
_ => new HttpResponseMessage(HttpStatusCode.Unauthorized) { ReasonPhrase = "Unauthorized" });
|
||
|
||
var result = await service.SendHeartbeatAsync();
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Contains("401", result.Detail);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendHeartbeatAsync_does_not_send_when_misconfigured()
|
||
{
|
||
var settings = Valid();
|
||
settings.WatchdogUrl = "http://watchdog.example.de"; // kein TLS
|
||
|
||
var service = Build(out FakeHandler handler, settings);
|
||
var result = await service.SendHeartbeatAsync();
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Null(handler.LastRequest); // gar nicht erst gesendet
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_returns_quietly_when_disabled()
|
||
{
|
||
var settings = Valid();
|
||
settings.WatchdogEnabled = false;
|
||
|
||
var service = Build(out FakeHandler handler, settings);
|
||
|
||
// StartAsync eines BackgroundService darf bei deaktivierter Konfiguration nicht werfen
|
||
// und nichts senden.
|
||
await service.StartAsync(CancellationToken.None);
|
||
await service.StopAsync(CancellationToken.None);
|
||
|
||
Assert.Null(handler.LastRequest);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ExecuteAsync_does_not_throw_when_enabled_but_misconfigured()
|
||
{
|
||
var settings = Valid();
|
||
settings.WatchdogEnabled = true;
|
||
settings.WatchdogToken = ""; // Fehlkonfiguration
|
||
|
||
var service = Build(out FakeHandler handler, settings);
|
||
|
||
await service.StartAsync(CancellationToken.None);
|
||
await service.StopAsync(CancellationToken.None);
|
||
|
||
Assert.Null(handler.LastRequest);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Running_service_starts_sending_after_being_enabled_without_restart()
|
||
{
|
||
// Der Nutzer schaltet den Heartbeat im Settings-Fenster ein: der laufende Dienst muss
|
||
// das übernehmen, ohne dass PolyTrader neu gestartet werden muss.
|
||
var settings = Valid();
|
||
settings.WatchdogEnabled = false;
|
||
|
||
var service = Build(out FakeHandler handler, settings);
|
||
await service.StartAsync(CancellationToken.None);
|
||
try
|
||
{
|
||
Assert.Null(handler.LastRequest); // deaktiviert -> nichts gesendet
|
||
|
||
settings.WatchdogEnabled = true;
|
||
service.ReloadSettings(); // genau das macht der Speichern-Button
|
||
|
||
Assert.True(await WaitForRequestAsync(handler),
|
||
"Nach dem Aktivieren wurde kein Heartbeat gesendet.");
|
||
}
|
||
finally
|
||
{
|
||
await service.StopAsync(CancellationToken.None);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Running_service_stops_sending_after_being_disabled_without_restart()
|
||
{
|
||
var settings = Valid();
|
||
var service = Build(out FakeHandler handler, settings);
|
||
|
||
await service.StartAsync(CancellationToken.None);
|
||
try
|
||
{
|
||
Assert.True(await WaitForRequestAsync(handler), "Es wurde kein initialer Heartbeat gesendet.");
|
||
|
||
settings.WatchdogEnabled = false;
|
||
service.ReloadSettings();
|
||
handler.Reset();
|
||
|
||
// Der Takt liegt bei 60 s; entscheidend ist, dass nach dem Ausschalten nichts mehr kommt.
|
||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||
Assert.Null(handler.LastRequest);
|
||
}
|
||
finally
|
||
{
|
||
await service.StopAsync(CancellationToken.None);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void ReloadSettings_picks_up_changed_values()
|
||
{
|
||
var settings = Valid();
|
||
settings.WatchdogUrl = "http://watchdog.example.de"; // zunächst ungültig
|
||
|
||
var service = new WatchdogHeartbeatService(new TerminalLogger(),
|
||
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))),
|
||
() => settings);
|
||
|
||
Assert.NotNull(service.GetConfigurationProblem());
|
||
|
||
settings.WatchdogUrl = "https://watchdog.example.de";
|
||
service.ReloadSettings();
|
||
|
||
Assert.Null(service.GetConfigurationProblem());
|
||
}
|
||
|
||
// ----- B.2: Status aus dem Log -----
|
||
|
||
[Fact]
|
||
public void DeriveStatus_is_ok_without_errors()
|
||
{
|
||
var logger = new TerminalLogger();
|
||
var service = new WatchdogHeartbeatService(logger,
|
||
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid());
|
||
|
||
Assert.Equal("ok", service.DeriveStatus().status);
|
||
}
|
||
|
||
[Fact]
|
||
public void DeriveStatus_reflects_logged_error_and_redacts_secrets()
|
||
{
|
||
var logger = new TerminalLogger();
|
||
var service = new WatchdogHeartbeatService(logger,
|
||
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid());
|
||
|
||
logger.Error("DB-Verbindung tot; wallet 0xDEADBEEFCAFEBABE1234 token enc:v1:AAAABBBBCCCC");
|
||
|
||
var (status, message) = service.DeriveStatus();
|
||
Assert.Equal("error", status);
|
||
Assert.Contains("DB-Verbindung tot", message);
|
||
// Secrets müssen entschärft sein (kein Wallet-Hex / kein enc-Token durchreichen).
|
||
Assert.DoesNotContain("DEADBEEFCAFEBABE", message);
|
||
Assert.DoesNotContain("AAAABBBBCCCC", message);
|
||
}
|
||
|
||
[Fact]
|
||
public void DeriveStatus_ignores_warning_and_info()
|
||
{
|
||
var logger = new TerminalLogger();
|
||
var service = new WatchdogHeartbeatService(logger,
|
||
new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid());
|
||
|
||
logger.Warning("nur eine Warnung");
|
||
logger.Info("nur Info");
|
||
|
||
// Warnungen/Infos eskalieren den Monitor bewusst nicht.
|
||
Assert.Equal("ok", service.DeriveStatus().status);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData("0xABCDEF0123456789 kaputt", "DEF012")]
|
||
[InlineData("secret enc:v1:ZZZZYYYYXXXX weg", "ZZZZYYYY")]
|
||
public void Sanitize_removes_secret_material(string input, string mustNotContain)
|
||
{
|
||
string cleaned = WatchdogHeartbeatService.Sanitize(input);
|
||
Assert.DoesNotContain(mustNotContain, cleaned);
|
||
}
|
||
|
||
[Fact]
|
||
public void Sanitize_truncates_to_200_chars()
|
||
{
|
||
string cleaned = WatchdogHeartbeatService.Sanitize(new string('x', 500));
|
||
Assert.True(cleaned.Length <= 200, $"Länge {cleaned.Length}");
|
||
}
|
||
|
||
// ----- B.3: Lifecycle-Events -----
|
||
|
||
[Fact]
|
||
public async Task SendEventAsync_posts_kind_and_source_to_event_endpoint()
|
||
{
|
||
var settings = Valid();
|
||
var service = Build(out FakeHandler handler, settings);
|
||
|
||
var result = await service.SendEventAsync("started", "info", "Test");
|
||
|
||
Assert.True(result.Success, result.Detail);
|
||
Assert.Equal("https://wd.example.de/api/watchdog/v1/event", handler.LastRequest!.RequestUri!.AbsoluteUri);
|
||
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||
Assert.Equal("started", doc.RootElement.GetProperty("kind").GetString());
|
||
Assert.Equal("polytrader", doc.RootElement.GetProperty("source").GetString());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Service_sends_started_then_stopped_graceful_over_its_lifecycle()
|
||
{
|
||
var settings = Valid();
|
||
var service = Build(out FakeHandler handler, settings);
|
||
|
||
await service.StartAsync(CancellationToken.None);
|
||
Assert.True(await WaitForBodyContainingAsync(handler, "\"kind\":\"started\""),
|
||
"Es wurde kein 'started'-Event gesendet.");
|
||
|
||
await service.StopAsync(CancellationToken.None);
|
||
Assert.True(handler.AnyBody(b => b.Contains("\"kind\":\"stopped_graceful\"")),
|
||
"Beim Herunterfahren wurde kein 'stopped_graceful'-Event gesendet.");
|
||
}
|
||
|
||
// ----- Helfer -----
|
||
|
||
/// <summary>Wartet begrenzt darauf, dass eine Anfrage mit passendem Body abgesetzt wurde.</summary>
|
||
private static async Task<bool> WaitForBodyContainingAsync(FakeHandler handler, string needle, int timeoutMs = 5000)
|
||
{
|
||
for (int waited = 0; waited < timeoutMs; waited += 50)
|
||
{
|
||
if (handler.AnyBody(b => b.Contains(needle))) return true;
|
||
await Task.Delay(50);
|
||
}
|
||
return handler.AnyBody(b => b.Contains(needle));
|
||
}
|
||
|
||
/// <summary>Wartet begrenzt darauf, dass der Dienst eine Anfrage abgesetzt hat.</summary>
|
||
private static async Task<bool> WaitForRequestAsync(FakeHandler handler, int timeoutMs = 5000)
|
||
{
|
||
for (int waited = 0; waited < timeoutMs; waited += 50)
|
||
{
|
||
if (handler.LastRequest != null) return true;
|
||
await Task.Delay(50);
|
||
}
|
||
return handler.LastRequest != null;
|
||
}
|
||
|
||
private static ServerSettings Valid() => new()
|
||
{
|
||
WatchdogEnabled = true,
|
||
WatchdogUrl = "https://wd.example.de",
|
||
WatchdogToken = "secret-token",
|
||
WatchdogSource = "polytrader",
|
||
WatchdogInstance = "prod-1",
|
||
WatchdogIntervalSeconds = 60
|
||
};
|
||
|
||
private static string Problem(Action<ServerSettings> configure)
|
||
{
|
||
var settings = Valid();
|
||
configure(settings);
|
||
var service = Build(out _, settings);
|
||
return service.GetConfigurationProblem() ?? string.Empty;
|
||
}
|
||
|
||
private static WatchdogHeartbeatService Build(
|
||
out FakeHandler handler,
|
||
ServerSettings settings,
|
||
Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
|
||
{
|
||
handler = new FakeHandler(responder ?? (_ => new HttpResponseMessage(HttpStatusCode.OK)));
|
||
return new WatchdogHeartbeatService(new TerminalLogger(), new HttpClient(handler), () => settings);
|
||
}
|
||
|
||
/// <summary>Fängt die Anfrage ab, statt sie zu senden.</summary>
|
||
private sealed class FakeHandler : HttpMessageHandler
|
||
{
|
||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||
|
||
public FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
|
||
|
||
public HttpRequestMessage? LastRequest { get; private set; }
|
||
public string? LastBody { get; private set; }
|
||
|
||
private readonly object _gate = new();
|
||
private readonly List<string> _bodies = new();
|
||
|
||
/// <summary>Thread-sicherer Prädikat-Check über alle bisher gesendeten Bodies.</summary>
|
||
public bool AnyBody(Func<string, bool> predicate)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
foreach (var b in _bodies)
|
||
if (predicate(b)) return true;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public void Reset()
|
||
{
|
||
LastRequest = null;
|
||
LastBody = null;
|
||
lock (_gate) _bodies.Clear();
|
||
}
|
||
|
||
protected override async Task<HttpResponseMessage> SendAsync(
|
||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||
{
|
||
LastRequest = request;
|
||
string body = string.Empty;
|
||
if (request.Content != null)
|
||
{
|
||
body = await request.Content.ReadAsStringAsync(cancellationToken);
|
||
}
|
||
LastBody = body;
|
||
lock (_gate) _bodies.Add(body);
|
||
return _responder(request);
|
||
}
|
||
}
|
||
}
|
||
}
|