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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTrader.Core.Modularity;
|
||||
using PolyTraderSharp.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests.WebUi
|
||||
{
|
||||
/// <summary>
|
||||
/// Moduleinstellungen über das WebUI (#560): anzeigen, aktivieren, deaktivieren - und die
|
||||
/// Einstellungsdatei dabei nie beschädigen.
|
||||
/// </summary>
|
||||
public class ModuleSettingsEndpointTests
|
||||
{
|
||||
private sealed class FakeModule : IPolyTraderModule
|
||||
{
|
||||
private readonly string? _blocker;
|
||||
public FakeModule(string name, string? blocker = null) { Name = name; _blocker = blocker; }
|
||||
public string Name { get; }
|
||||
public string DbPrefix => "fake_";
|
||||
public void RegisterServices(IServiceCollection services, IConfiguration configuration) { }
|
||||
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
|
||||
public string? GetActivationBlocker(IConfiguration configuration) => _blocker;
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static JsonElement ByName(JsonElement modules, string name) =>
|
||||
modules.EnumerateArray().Single(m => m.GetProperty("name").GetString() == name);
|
||||
|
||||
[Fact]
|
||||
public async Task Laufendes_Modul_ohne_Eintrag_ist_aktiv_die_uebrigen_starten_nach_Neustart()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync(s =>
|
||||
s.AddSingleton<IPolyTraderModule>(new FakeModule("CopyTrading")));
|
||||
|
||||
var modules = await web.GetJsonAsync("/api/modules");
|
||||
|
||||
var ct = ByName(modules, "CopyTrading");
|
||||
Assert.Equal("Aktiv", ct.GetProperty("status").GetString());
|
||||
Assert.Equal("Deaktivieren", ct.GetProperty("action").GetString());
|
||||
Assert.True(ct.GetProperty("canToggle").GetBoolean());
|
||||
Assert.Equal("Startet nach Neustart", ByName(modules, "Supervisor").GetProperty("status").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deaktivieren_schreibt_DisabledModules_und_laesst_Schluessel_in_der_Datei_stehen()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync(s =>
|
||||
s.AddSingleton<IPolyTraderModule>(new FakeModule("CopyTrading")));
|
||||
new ServerSettings
|
||||
{
|
||||
LicenseKey = "enc:v1:LIZENZ",
|
||||
WatchdogToken = "enc:v1:WATCHDOG",
|
||||
PolygonRpcUrl = "wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY",
|
||||
VpnLocation = "se"
|
||||
}.Save(web.SettingsPath);
|
||||
|
||||
var (status, body) = await web.PostJsonAsync("/api/modules/copytrading/enabled", new { enabled = false });
|
||||
|
||||
Assert.Equal(200, status);
|
||||
Assert.Equal("Aktiv – stoppt nach Neustart", body.GetProperty("status").GetString());
|
||||
|
||||
var written = ServerSettings.Load(web.SettingsPath);
|
||||
Assert.Equal(new[] { "CopyTrading" }, written.DisabledModules); // kanonischer Name, nicht "copytrading"
|
||||
Assert.Equal("enc:v1:LIZENZ", written.LicenseKey);
|
||||
Assert.Equal("enc:v1:WATCHDOG", written.WatchdogToken);
|
||||
Assert.Equal("wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY", written.PolygonRpcUrl);
|
||||
Assert.Equal("se", written.VpnLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wiederholter_Aufruf_aendert_nichts_mehr_und_Aktivieren_nimmt_den_Eintrag_zurueck()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync();
|
||||
|
||||
await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
|
||||
await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
|
||||
Assert.Equal(new[] { "Supervisor" }, ServerSettings.Load(web.SettingsPath).DisabledModules);
|
||||
|
||||
var (status, body) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = true });
|
||||
|
||||
Assert.Equal(200, status);
|
||||
Assert.Empty(ServerSettings.Load(web.SettingsPath).DisabledModules);
|
||||
Assert.True(body.GetProperty("desiredEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unbekanntes_Modul_404_fehlender_Zustand_400()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync();
|
||||
|
||||
var (unknown, _) = await web.PostJsonAsync("/api/modules/GibtEsNicht/enabled", new { enabled = false });
|
||||
var (missing, _) = await web.PostJsonAsync("/api/modules/Supervisor/enabled");
|
||||
|
||||
Assert.Equal(404, unknown);
|
||||
Assert.Equal(400, missing);
|
||||
Assert.False(File.Exists(web.SettingsPath)); // nichts geschrieben
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Modul_mit_Blocker_laesst_sich_nicht_schalten()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync(s =>
|
||||
s.AddSingleton(new KnownModules(new IPolyTraderModule[] { new FakeModule("Supervisor", blocker: "OpenRouter-Key fehlt") })));
|
||||
|
||||
var modules = await web.GetJsonAsync("/api/modules");
|
||||
var sup = ByName(modules, "Supervisor");
|
||||
Assert.False(sup.GetProperty("canToggle").GetBoolean());
|
||||
Assert.Equal("OpenRouter-Key fehlt", sup.GetProperty("hint").GetString());
|
||||
|
||||
var (status, _) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = true });
|
||||
Assert.Equal(409, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlesbare_Einstellungsdatei_wird_nicht_angefasst()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync();
|
||||
const string kaputt = "<ServerSettings><LicenseKey>enc:v1:LIZENZ</LicenseKey><abgeschnitten";
|
||||
File.WriteAllText(web.SettingsPath, kaputt);
|
||||
|
||||
var (status, body) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
|
||||
|
||||
Assert.Equal(500, status);
|
||||
Assert.Contains("nicht lesbar", body.GetProperty("detail").GetString());
|
||||
Assert.Equal(kaputt, File.ReadAllText(web.SettingsPath)); // Byte fuer Byte unveraendert
|
||||
|
||||
// Anzeigen geht weiter, umschalten wird nicht angeboten.
|
||||
var modules = await web.GetJsonAsync("/api/modules");
|
||||
Assert.All(modules.EnumerateArray(), m => Assert.False(m.GetProperty("canToggle").GetBoolean()));
|
||||
}
|
||||
|
||||
// ---- ModuleActivationView ---------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true, null, "Aktiv", "Deaktivieren", true)]
|
||||
[InlineData(true, false, null, "Aktiv – stoppt nach Neustart", "Aktivieren", true)]
|
||||
[InlineData(false, true, null, "Startet nach Neustart", "Deaktivieren", true)]
|
||||
[InlineData(false, false, null, "Deaktiviert", "Aktivieren", true)]
|
||||
[InlineData(false, true, "API-Key fehlt", "⚠ Nicht aktivierbar", "—", false)]
|
||||
public void Anzeige_folgt_den_Regeln_des_Desktops(bool running, bool desired, string? blocker,
|
||||
string status, string action, bool canToggle)
|
||||
{
|
||||
var view = ModuleActivationView.Describe(new ModuleActivationInfo("X", running, blocker), desired);
|
||||
|
||||
Assert.Equal(status, view.Status);
|
||||
Assert.Equal(action, view.Action);
|
||||
Assert.Equal(canToggle, view.CanToggle);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user