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>
183 lines
8.9 KiB
C#
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 */ }
|
|
}
|
|
}
|
|
}
|