Files
ClawdDotNet/tests/ClawdDotNet.App.Tests/Services/AdministrationTests.cs
T
Richard 1e9cb789c8 Weboberflaeche hinzugefuegt und Fachlogik nach App herausgeloest (#493, #494)
- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming.

- Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften).

- ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert.

- Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
2026-09-07 21:56:02 +02:00

348 lines
12 KiB
C#

using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Die Regeln, die bis zum 2026-09-06 in den Avalonia-Ansichtsmodellen standen.
///
/// <para>Dass sie dort standen, war der Grund für diese Tests: Ein Ansichtsmodell hat
/// keine Testabdeckung — das sagt der Oberflächen-Leitfaden ausdrücklich —, und mit der
/// zweiten Oberfläche wären dieselben Regeln ein zweites Mal geschrieben worden. Jetzt
/// gibt es sie einmal, und ab hier ist geprüft, dass beide Oberflächen dasselbe
/// bekommen.</para>
/// </summary>
public sealed class AgentAdministrationTests
{
[Fact]
public void Eine_doppelte_Agenten_ID_wird_abgelehnt()
{
using var host = new TestHost(TestHost.Agent("scout"));
var ergebnis = host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
ergebnis.Ok.ShouldBeFalse();
ergebnis.Message.ShouldContain("existiert bereits");
host.Host.Instance.Agents.Count.ShouldBe(1);
}
[Fact]
public void Die_Pruefung_ignoriert_Gross_und_Kleinschreibung()
{
// Zwei Agenten, die sich nur darin unterscheiden, waeren auf der Zielplattform
// zwei Ordner und fuer jeden Menschen derselbe Agent.
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.AgentAdmin.Add(new NewAgent("SCOUT", "Scout", "modell")).Ok.ShouldBeFalse();
}
[Fact]
public void Ohne_ID_wird_kein_Agent_angelegt()
{
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent(" ", "Scout", "modell")).Ok.ShouldBeFalse();
host.Host.Instance.Agents.ShouldBeEmpty();
}
[Fact]
public void Ein_angelegter_Agent_ueberlebt_den_Neustart()
{
// Der Kern des Befunds: LoadInstanceConfig leert die Agentenliste und baut sie
// aus den Ordnern Agents/Agent-* wieder auf. Ein Agent, der nur in
// InstanceSettings.json stand, war nach dem naechsten Start weg — genau das tat
// die Oberflaeche bis zum 2026-09-06.
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell", "Späher")).Ok.ShouldBeTrue();
var nachNeustart = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
nachNeustart.Agents.ShouldContain(a => a.AgentId == "scout" && a.DisplayName == "Scout");
}
[Fact]
public void Aenderungen_an_einem_Agenten_ueberleben_den_Neustart()
{
// Dieselbe Ursache aus der anderen Richtung: Bis zum 2026-09-06 rief niemand im
// laufenden Betrieb SaveAgentSettings. Modell, Prompt und Budget standen nur in
// InstanceSettings.json — es sah aus wie Speichern und war keines.
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "altes-modell"));
var agent = host.Host.AgentAdmin.Find("scout")!;
agent.Model = "neues-modell";
agent.Budget.DailyTokens = 12_345;
agent.Identity = "# Identity: geändert";
host.Host.AgentAdmin.Save();
var nachNeustart = host.Host.Directories
.LoadInstanceConfig(host.Host.InstancePath).Agents
.Single(a => a.AgentId == "scout");
nachNeustart.Model.ShouldBe("neues-modell");
nachNeustart.Budget.DailyTokens.ShouldBe(12_345);
nachNeustart.Identity.ShouldBe("# Identity: geändert");
}
[Fact]
public void Ein_Werkzeug_bleibt_ueber_den_Neustart_zugelassen()
{
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true);
host.Host.Directories
.LoadInstanceConfig(host.Host.InstancePath).Agents
.Single(a => a.AgentId == "scout")
.Tools.ShouldContainKey("FileRW");
}
[Fact]
public void Jede_Aenderung_meldet_sich()
{
// Ohne diese Meldung erschiene ein in der Weboberflaeche angelegter Agent im
// laufenden Fenster nicht — der ganze Grund, warum es das Ereignis gibt.
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
host.Changes.ShouldHaveSingleItem();
host.Changes[0].Kind.ShouldBe(InstanceChangeKind.Agents);
host.Changes[0].Id.ShouldBe("scout");
}
[Fact]
public void Ein_entfernter_Agent_kommt_nach_dem_Neustart_nicht_wieder()
{
// Die Gegenrichtung desselben Befunds: Nur aus der Liste zu streichen genuegte
// nicht — der Ordner blieb liegen, und beim naechsten Start stand der Agent
// wieder da.
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue();
host.Host.Directories
.LoadInstanceConfig(host.Host.InstancePath).Agents
.ShouldNotContain(a => a.AgentId == "scout");
}
[Fact]
public void Ein_entfernter_Agent_behaelt_seine_Arbeit()
{
// Umbenannt, nicht geloescht. Im Arbeitsordner steht seine Arbeit, und ein Klick
// in einer Liste ist keine Entscheidung, sie wegzuwerfen.
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
var arbeitsdatei = Path.Combine(
host.Host.AgentAdmin.Find("scout")!.AgentDir, "Workspace", "ergebnis.txt");
File.WriteAllText(arbeitsdatei, "wichtig");
host.Host.AgentAdmin.Remove("scout").Ok.ShouldBeTrue();
var agentsDir = Path.Combine(host.Host.InstancePath, "Agents");
Directory.GetDirectories(agentsDir, "Entfernt-*").ShouldHaveSingleItem();
Directory.GetFiles(agentsDir, "ergebnis.txt", SearchOption.AllDirectories)
.ShouldHaveSingleItem();
}
[Fact]
public void Ein_Werkzeug_zulassen_legt_eine_leere_Konfiguration_an()
{
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.AgentAdmin.SetToolEnabled("scout", "FileRW", enabled: true).Ok.ShouldBeTrue();
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("FileRW");
host.Host.AgentAdmin.Find("scout")!.Tools["FileRW"].ShouldBeEmpty();
}
[Fact]
public void Ein_Werkzeug_wegnehmen_entfernt_auch_seine_Einstellungen()
{
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.AgentAdmin.SetToolSettings(
"scout", "Mail", new Dictionary<string, object?> { ["host"] = "mail.example" });
host.Host.AgentAdmin.SetToolEnabled("scout", "Mail", enabled: false);
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldNotContainKey("Mail");
}
[Fact]
public void Einstellungen_zu_schreiben_heisst_das_Werkzeug_zu_wollen()
{
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.AgentAdmin.SetToolSettings(
"scout", "Mail", new Dictionary<string, object?> { ["host"] = "mail.example" });
host.Host.AgentAdmin.Find("scout")!.Tools.ShouldContainKey("Mail");
}
[Fact]
public void Ein_unbekannter_Agent_liefert_eine_Antwort_und_keine_Ausnahme()
{
using var host = new TestHost();
host.Host.AgentAdmin.Remove("gibtsnicht").Ok.ShouldBeFalse();
host.Host.AgentAdmin.SetToolEnabled("gibtsnicht", "FileRW", true).Ok.ShouldBeFalse();
host.Changes.ShouldBeEmpty();
}
}
public sealed class JobAdministrationTests
{
[Fact]
public void Ein_unlesbarer_Zeitplan_wird_abgelehnt()
{
using var host = new TestHost(TestHost.Agent("scout"));
var ergebnis = host.Host.JobAdmin.Add(new JobDefinition(
"scout", JobKind.AgentWakeup, Cron: "jeden zweiten Dienstag"));
ergebnis.Ok.ShouldBeFalse();
ergebnis.Message.ShouldContain("nicht lesbar");
host.Host.AgentAdmin.Find("scout")!.Scheduler.ShouldBeNull();
}
[Fact]
public void Ein_leerer_Zeitplan_ist_kein_Fehler()
{
// Leer heisst „nie" und ist eine gueltige Angabe. Nur unlesbar ist ein Fehler.
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup)).Ok.ShouldBeTrue();
}
[Fact]
public void Ein_Wakeup_laesst_sich_nicht_abschalten()
{
// SchedulerConfig hat keinen Schalter, und einen hier zu erfinden hiesse, dass
// der Scanner ihn erst einmal lesen muesste. Sonst liefe ein „abgeschalteter"
// Wakeup munter weiter.
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
var ergebnis = host.Host.JobAdmin.SetEnabled(
"scout", JobAdministration.WakeupId("scout"), enabled: false);
ergebnis.Ok.ShouldBeFalse();
ergebnis.Message.ShouldContain("Nur Tool-Jobs");
}
[Fact]
public void Ein_Tool_Job_laesst_sich_abschalten_ohne_ihn_zu_verlieren()
{
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.JobAdmin.Add(new JobDefinition(
"scout", JobKind.ToolJob, ToolName: "Mail", JobTypeId: "PollJob", Cron: "0 * * * *"));
var job = host.Host.JobAdmin.List().Single();
host.Host.JobAdmin.SetEnabled("scout", job.JobId, enabled: false).Ok.ShouldBeTrue();
var danach = host.Host.JobAdmin.List().Single();
danach.Enabled.ShouldBeFalse();
danach.ToolName.ShouldBe("Mail");
}
[Fact]
public void Ohne_Scanner_gilt_ein_Wakeup_als_nicht_aktiv()
{
// Er laeuft, solange der Scanner laeuft — der ist der einzige periodische
// Treiber. Ohne ihn steht er, ohne dass jemand ihn abgeschaltet haette.
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
host.Host.Scanner.ShouldBeNull();
host.Host.JobAdmin.List().Single().Enabled.ShouldBeFalse();
}
[Fact]
public void Der_naechste_Lauf_wird_aus_dem_Zeitplan_gerechnet()
{
var (next, valid) = JobAdministration.NextRun("0 * * * *");
valid.ShouldBeTrue();
next.ShouldNotBeNull();
next!.Value.ShouldBeGreaterThan(DateTime.UtcNow);
}
[Fact]
public void Ein_unlesbarer_Zeitplan_ist_von_einem_fehlenden_zu_unterscheiden()
{
JobAdministration.NextRun("").ShouldBe((null, true));
JobAdministration.NextRun("kaputt").ShouldBe((null, false));
}
[Fact]
public void Ein_entfernter_Wakeup_ist_weg_und_der_Agent_bleibt()
{
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.JobAdmin.Add(new JobDefinition("scout", JobKind.AgentWakeup, Cron: "0 * * * *"));
host.Host.JobAdmin.Remove("scout", JobAdministration.WakeupId("scout"), JobKind.AgentWakeup)
.Ok.ShouldBeTrue();
host.Host.AgentAdmin.Find("scout").ShouldNotBeNull();
host.Host.JobAdmin.List().ShouldBeEmpty();
}
}
public sealed class ServiceAdministrationTests
{
[Fact]
public void Die_eingebauten_Dienste_kommen_genau_einmal_dazu()
{
using var host = new TestHost();
host.Host.ServiceAdmin.EnsureBuiltIns();
var nachDemErsten = host.Host.Instance.Services.Count;
host.Host.ServiceAdmin.EnsureBuiltIns();
nachDemErsten.ShouldBe(BuiltInServices.CreateDefaults().Count);
host.Host.Instance.Services.Count.ShouldBe(nachDemErsten);
}
[Fact]
public void Ein_abgeschalteter_eingebauter_Dienst_bleibt_abgeschaltet()
{
using var host = new TestHost();
host.Host.ServiceAdmin.EnsureBuiltIns();
var dienst = host.Host.Instance.Services.First(s => s.BuiltIn);
host.Host.ServiceAdmin.SetEnabled(dienst.ServiceId, enabled: false);
host.Host.ServiceAdmin.EnsureBuiltIns();
host.Host.ServiceAdmin.Find(dienst.ServiceId)!.Enabled.ShouldBeFalse();
}
[Fact]
public void Ein_Dienst_ohne_Namen_wird_abgelehnt()
{
using var host = new TestHost();
host.Host.ServiceAdmin.Add(new NewService(" ", "HTTP", 8080)).Ok.ShouldBeFalse();
host.Host.Instance.Services.ShouldBeEmpty();
}
}