using ClawdDotNet.Core.Instances;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
///
/// manager.json und der Protokollauszug — die beiden Stellen, an denen der Manager
/// mit dem Dateisystem spricht (#528, Stufe 3).
///
public sealed class ManagerSettingsTests : IDisposable
{
private readonly string _dir = Path.Combine(
Path.GetTempPath(), "clawd-tests", "mgr-" + Guid.NewGuid().ToString("N")[..8]);
public ManagerSettingsTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
[Fact]
public void Eine_fehlende_Datei_wird_mit_Vorgaben_angelegt()
{
var store = new ManagerSettingsStore(_dir);
var settings = store.Load();
File.Exists(store.SettingsPath).ShouldBeTrue("Der Ort soll sichtbar werden.");
settings.WebUiPort.ShouldBe(7000);
settings.UpdateChannel.ShouldBe("prod");
}
///
/// Master-Token und Lizenzschlüssel liegen in der Datei nicht im Klartext — zur
/// Laufzeit aber schon, sonst könnte niemand damit arbeiten.
///
[Fact]
public void Geheimnisse_stehen_nicht_im_Klartext_in_der_Datei()
{
var store = new ManagerSettingsStore(_dir);
store.Load();
store.Settings.DeploymentcenterMasterToken = "dc_master_geheim_1234567890";
store.Settings.LicenseKey = "LIZ-1234-5678";
store.Save();
File.ReadAllText(store.SettingsPath).ShouldNotContain("dc_master_geheim_1234567890");
File.ReadAllText(store.SettingsPath).ShouldNotContain("LIZ-1234-5678");
var erneut = new ManagerSettingsStore(_dir).Load();
erneut.DeploymentcenterMasterToken.ShouldBe("dc_master_geheim_1234567890");
erneut.LicenseKey.ShouldBe("LIZ-1234-5678");
}
///
/// Eine von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark,
/// und sie darf trotzdem gelten.
///
/// Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete
/// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag.
///
[Fact]
public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch()
{
var pfad = Path.Combine(_dir, "manager.json");
// Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach
// dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad.
var inhalt = """
{
"instanceExecutable": "J:\\Programme\\ClawdDotNet.exe",
"webUiPort": 7009,
"deploymentcenterUrl": ""
}
""";
File.WriteAllText(pfad, inhalt, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
File.ReadAllBytes(pfad)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM.");
var store = new ManagerSettingsStore(_dir);
var settings = store.Load();
store.LoadProblem.ShouldBeNull();
settings.InstanceExecutable.ShouldBe(@"J:\Programme\ClawdDotNet.exe");
settings.WebUiPort.ShouldBe(7009);
settings.DeploymentcenterUrl.ShouldBe("");
}
///
/// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar
/// keine Instanz mehr, und das ist der schlechtere Zustand.
///
[Fact]
public void Eine_kaputte_Datei_verhindert_den_Start_nicht()
{
File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON");
var store = new ManagerSettingsStore(_dir);
store.Load().WebUiPort.ShouldBe(7000);
// Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit
// Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist.
store.LoadProblem.ShouldNotBeNullOrWhiteSpace();
}
///
/// Unsinnige Zahlen können in einer von Hand bearbeitbaren Datei stehen. Ein
/// Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null Fehlversuchen ein
/// Alarm bei jedem Aussetzer.
///
[Fact]
public void Unsinnige_Aufsichtswerte_werden_eingefangen()
{
var settings = new ManagerSettings
{
ProbeIntervalSeconds = 0,
StartTimeoutSeconds = -5,
SilentChecksBeforeAlarm = 0,
StopGraceSeconds = 99999
};
var options = settings.ToSupervisionOptions();
options.ProbeInterval.ShouldBeGreaterThan(TimeSpan.Zero);
options.StartTimeout.ShouldBeGreaterThan(TimeSpan.Zero);
options.SilentChecksBeforeAlarm.ShouldBeGreaterThanOrEqualTo(1);
options.StopGrace.ShouldBeLessThanOrEqualTo(TimeSpan.FromMinutes(10));
}
}
/// Der Protokollauszug, der einer Absturzmeldung erst ihren Wert gibt.
public sealed class InstanceLogExcerptTests : IDisposable
{
private readonly string _instanz = Path.Combine(
Path.GetTempPath(), "clawd-tests", "log-" + Guid.NewGuid().ToString("N")[..8]);
public InstanceLogExcerptTests() => Directory.CreateDirectory(_instanz);
public void Dispose()
{
try { Directory.Delete(_instanz, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
private void Schreibe(string tag, string modul, params string[] zeilen)
{
var dir = Path.Combine(_instanz, "Logs", tag);
Directory.CreateDirectory(dir);
File.WriteAllLines(Path.Combine(dir, modul), zeilen);
}
[Fact]
public void Ohne_Protokoll_kommt_nichts_zurueck()
{
// Kein Fehler, sondern ein Befund: Ein Prozess, der erschlagen wurde, bevor sein
// Schreibpuffer geleert war, hinterlaesst genau nichts (#529).
InstanceLogExcerpt.Read(_instanz).ShouldBeEmpty();
}
///
/// Module beschreiben denselben Zeitraum. Bei einem Absturz will man wissen, was
/// gleichzeitig passiert ist — nicht erst alles aus einer Datei, dann alles aus der
/// nächsten.
///
[Fact]
public void Die_Module_werden_nach_Zeit_zusammengefuehrt()
{
Schreibe("2026-09-08", "Startup.log",
"[17:26:48.100] [INF] Instanz Test2 angelegt",
"[17:26:50.900] [INF] Application is shutting down");
Schreibe("2026-09-08", "Web.log",
"[17:26:49.500] [INF] Weboberfläche beendet");
var zeilen = InstanceLogExcerpt.Read(_instanz);
zeilen.Count.ShouldBe(3);
zeilen[0].ShouldContain("17:26:48");
zeilen[1].ShouldContain("17:26:49");
zeilen[2].ShouldContain("17:26:50");
// Aus welcher Datei eine Zeile stammt, gehoert dazu.
zeilen[1].ShouldStartWith("Web.log:");
}
[Fact]
public void Nur_der_juengste_Tag_zaehlt()
{
Schreibe("2026-09-01", "Startup.log", "[10:00:00.000] [INF] uralt");
Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] frisch");
InstanceLogExcerpt.Read(_instanz).ShouldHaveSingleItem().ShouldContain("frisch");
}
[Fact]
public void Es_werden_nur_die_letzten_Zeilen_genommen()
{
Schreibe("2026-09-08", "Startup.log",
Enumerable.Range(0, 500).Select(i => $"[10:00:{i % 60:00}.000] [INF] Zeile {i}").ToArray());
InstanceLogExcerpt.Read(_instanz, lines: 10).Count.ShouldBeLessThanOrEqualTo(10);
}
///
/// Die Datei kann in genau diesem Moment noch beschrieben werden — von einer Instanz,
/// die gerade endet. Wer sie exklusiv öffnen wollte, bekäme im Zweifel gar nichts.
///
[Fact]
public void Eine_offen_beschriebene_Datei_laesst_sich_lesen()
{
Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] erste Zeile");
var pfad = Path.Combine(_instanz, "Logs", "2026-09-08", "Startup.log");
using var offen = new FileStream(
pfad, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
InstanceLogExcerpt.Read(_instanz).ShouldNotBeEmpty();
}
[Fact]
public void Ein_eigenes_Protokollverzeichnis_wird_beachtet()
{
var eigen = Path.Combine(_instanz, "AndereLogs");
Directory.CreateDirectory(Path.Combine(eigen, "2026-09-08"));
File.WriteAllLines(
Path.Combine(eigen, "2026-09-08", "Startup.log"),
["[10:00:00.000] [INF] woanders"]);
var config = new ClawdDotNet.Core.Config.InstanceConfig
{
WorkingDirectory = _instanz,
LogDirectory = "AndereLogs"
};
InstanceLogExcerpt.Read(_instanz, config).ShouldHaveSingleItem().ShouldContain("woanders");
}
}