Files
PolyTraderSharp/tests/PolyTrader.Tests/WebUi/WebUiTestHost.cs
T
RichardandClaude Opus 5 68d8d6f8d2 Module im WebUI aktivieren und deaktivieren
Umsetzung von #560.

Die Ansicht "Moduleinstellungen" zeigte bisher nur an, ob ein Modul laeuft. Jetzt
laesst sich dort wie im Modul-Tab des Desktops festlegen, welche Module beim
naechsten Start geladen werden. POST /api/modules/{name}/enabled nimmt den
gewuenschten Zustand ausdruecklich entgegen ({"enabled": true|false}) und
schaltet nicht einfach um. Zwei Klicks aus zwei Fenstern heben sich damit
nicht auf, und ein wiederholter Aufruf aendert nichts mehr.

Fehlerfaelle:
- Unbekanntes Modul: 404.
- Modul mit Aktivierungs-Blocker: 409.
- Fehlender Zustand: 400. Die Pruefung war zuerst lueckenhaft, der Test hat es
  gefunden: Mit "bool Enabled" machte ein leerer Rumpf {} daraus false, und ein
  unvollstaendiger Aufruf haette das Modul deaktiviert.

Gemeinsame Quelle statt Kopien:
- KnownModules: Die Modulliste stand viermal im Code (Program, CoreViews,
  ModuleViews, hartkodiert im WebUI). Der Start registriert jetzt die eine
  Liste.
- ModuleActivationView: Die Statusregeln des Desktops ("Aktiv - stoppt nach
  Neustart" usw.) liegen im Core. Desktop und WebUI zeigen dasselbe.

ServerSettingsFile - gefunden, bevor das WebUI in die Datei schreiben durfte:
ServerSettings.Load verschluckt jeden Lesefehler und liefert Vorgaben. "Frisch
lesen, ein Feld aendern, speichern" haette eine gesperrte oder beschaedigte
server_settings.xml durch Vorgaben ersetzt, und mit ihr Lizenzschluessel,
Watchdog-Token, Mullvad-Account und Alchemy-Key. Genau so schrieb auch der
Modul-Schalter des Desktops. ServerSettingsFile:
- liest streng und schreibt bei unlesbarer Datei nichts,
- schreibt atomar ueber eine Nachbardatei,
- erhaelt unter Linux die Dateirechte,
- serialisiert Aenderungen prozessweit.
Der Desktop-Schalter benutzt ihn jetzt ebenfalls.

Tests: Lizenzschluessel, Token und Alchemy-Key bleiben beim Umschalten in der
Datei; eine kaputte Datei bleibt Byte fuer Byte unveraendert und die Anzeige
bietet dann kein Umschalten an; Blocker, 404, 400, Idempotenz; die
Statustabelle; strenges Lesen, atomares Schreiben und Rechte am Dateiobjekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:03:31 +02:00

183 lines
8.9 KiB
C#

using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PolyTrader.Modules.CopyTrading.Persistence;
using PolyTrader.Modules.CopyTrading.Persistence.Ef;
using PolyTrader.Modules.ResolutionFarming.Persistence;
using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
using PolyTrader.Tests.TestSupport;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Hosting;
using PolyTrader.Web.Security;
using PolyTraderSharp;
using PolyTraderSharp.Services;
namespace PolyTrader.Tests.WebUi
{
/// <summary>
/// Startet das WebUI in einem Testserver und spricht es ueber echtes HTTP an (#568).
///
/// <para>Die Anfragekette kommt aus <see cref="WebUiHostingExtensions.UsePolyTraderWebPipeline"/>,
/// also exakt aus dem Betrieb: statische Dateien, Token-Pruefung, Routen. Ersetzt wird nur, was
/// ausserhalb des Prozesses liegt - Kestrel durch den Testserver, MySQL durch den
/// InMemory-Provider.</para>
///
/// <para>Vorher rief keiner der WebUI-Tests eine Route auf. Sie bauten die Logik der Endpunkte im
/// Test nach und prueften den Nachbau - ein solcher Test bleibt gruen, egal was der Endpunkt tut.</para>
/// </summary>
internal sealed class WebUiTestHost : IAsyncDisposable
{
public const string Token = "webui-test-token-7f3a9c1e5b2d4f6a8c0e";
private readonly IHost _host;
private readonly string _settingsDirectory;
public TradingState State { get; }
public CopyTradingState CopyState { get; }
public TerminalLogger Logger { get; }
public JobManager Jobs { get; }
public WebUiOptions WebOptions { get; }
/// <summary>Eigene server_settings.xml je Testhost in einem temporaeren Ordner - nie die echte.</summary>
public string SettingsPath => System.IO.Path.Combine(_settingsDirectory, "server_settings.xml");
/// <summary>Mit gueltigem Token.</summary>
public HttpClient Client { get; }
/// <summary>Ohne Token - fuer die Pruefung der Zugangssperre.</summary>
public HttpClient Anonymous { get; }
private WebUiTestHost(IHost host, TradingState state, CopyTradingState copyState,
TerminalLogger logger, JobManager jobs, WebUiOptions options, string settingsDirectory)
{
_host = host;
_settingsDirectory = settingsDirectory;
State = state;
CopyState = copyState;
Logger = logger;
Jobs = jobs;
WebOptions = options;
var server = host.GetTestServer();
Client = server.CreateClient();
Client.DefaultRequestHeaders.Add(TokenAuthMiddleware.TokenHeaderName, Token);
Anonymous = server.CreateClient();
}
/// <summary>
/// Startet den Host. Die Typen, die Endpunkte direkt als Parameter nehmen (TradingState,
/// CopyTradingState, TerminalLogger, JobManager), sind immer registriert: Minimal APIs werten
/// einen nicht registrierten Parametertyp sonst als Anfragekoerper, und dann scheitert jede
/// GET-Route schon beim Aufbau.
/// </summary>
public static async Task<WebUiTestHost> StartAsync(
Action<IServiceCollection>? configureServices = null,
Action<WebUiOptions>? configureOptions = null)
{
var state = new TradingState();
var copyState = new CopyTradingState();
var logger = new TerminalLogger();
var jobs = new JobManager();
var options = new WebUiOptions { Enabled = true, AuthToken = Token };
configureOptions?.Invoke(options);
string settingsDirectory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "polytrader-webui-" + Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(settingsDirectory);
var host = await new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(Microsoft.Extensions.Options.Options.Create(options));
services.AddSingleton(state);
services.AddSingleton(copyState);
services.AddSingleton(logger);
services.AddSingleton(jobs);
// Wie im Betrieb (Program): alle Module und die Einstellungsdatei. Tests koennen beides
// in configureServices ersetzen - die letzte Registrierung gewinnt.
services.AddSingleton(new PolyTrader.Core.Modularity.KnownModules(new PolyTrader.Core.Modularity.IPolyTraderModule[]
{
new PolyTrader.Modules.CopyTrading.CopyTradingModule(),
new PolyTrader.Modules.ResolutionFarming.ResolutionFarmingModule(),
new PolyTrader.Modules.Supervisor.SupervisorModule(),
new PolyTrader.Modules.Accounting.AccountingModule()
}));
services.AddSingleton(new PolyTrader.Core.Configuration.ServerSettingsFile(
System.IO.Path.Combine(settingsDirectory, "server_settings.xml")));
configureServices?.Invoke(services);
});
web.Configure(app => app.UsePolyTraderWebPipeline());
})
.StartAsync();
return new WebUiTestHost(host, state, copyState, logger, jobs, options, settingsDirectory);
}
/// <summary>CopyTrading-Repositories auf einer eigenen InMemory-Datenbank.</summary>
public static void AddCopyTradingStore(IServiceCollection services)
{
services.AddSingleton<IDbContextFactory<CopyTradingDbContext>>(
new InMemoryContextFactory<CopyTradingDbContext>(o => new CopyTradingDbContext(o)));
services.AddSingleton<ITrackedTraderRepository, EfTrackedTraderRepository>();
services.AddSingleton<ICopyTradeLogRepository, EfCopyTradeLogRepository>();
services.AddSingleton<ICopyTradingAccountSettingsRepository, EfCopyTradingAccountSettingsRepository>();
}
/// <summary>ResolutionFarming-Repositories auf einer eigenen InMemory-Datenbank.</summary>
public static void AddResolutionFarmingStore(IServiceCollection services)
{
services.AddSingleton<IDbContextFactory<ResolutionFarmingDbContext>>(
new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o)));
services.AddSingleton<IRfSettingsRepository, EfRfSettingsRepository>();
services.AddSingleton<IRfCandidateRepository, EfRfCandidateRepository>();
services.AddSingleton<IRfPositionRepository, EfRfPositionRepository>();
services.AddSingleton<IRfClosedTradeRepository, EfRfClosedTradeRepository>();
}
public T Get<T>() where T : notnull => _host.Services.GetRequiredService<T>();
/// <summary>GET mit Token; wirft mit Statuscode und Antworttext, wenn die Route scheitert.</summary>
public async Task<JsonElement> GetJsonAsync(string path)
{
using var res = await Client.GetAsync(path);
string body = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new InvalidOperationException($"GET {path} -> {(int)res.StatusCode}: {body}");
return Parse(body);
}
/// <summary>POST mit Token; liefert Status und Antwort, ohne bei Fehlern zu werfen.</summary>
public async Task<(int Status, JsonElement Body)> PostJsonAsync(string path, object? payload = null)
{
using var content = new StringContent(
payload == null ? "{}" : JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var res = await Client.PostAsync(path, content);
string body = await res.Content.ReadAsStringAsync();
return ((int)res.StatusCode, Parse(body));
}
private static JsonElement Parse(string body) =>
string.IsNullOrWhiteSpace(body)
? default
: JsonDocument.Parse(body).RootElement.Clone();
public async ValueTask DisposeAsync()
{
Client.Dispose();
Anonymous.Dispose();
await _host.StopAsync();
_host.Dispose();
try { System.IO.Directory.Delete(_settingsDirectory, recursive: true); } catch { /* temporaer, darf liegen bleiben */ }
}
}
}