Files
PolyTraderSharp/tests/PolyTrader.Tests/WebUi/ModuleSettingsEndpointTests.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

160 lines
7.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}