using System.Text.Json;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
///
/// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am
/// Tag, aufgeräumt, auf Wunsch hochgeladen.
///
public sealed class ManagerBackupsTests : IAsyncDisposable
{
private const string Lizenz = "CLAWD-TEST-0000-1111-2222";
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "mbak-" + Guid.NewGuid().ToString("N")[..8]);
private readonly InstanceDirectoryManager _verzeichnisse;
private readonly ManagerSettingsStore _settings;
private readonly FakeUploader _upload = new();
private readonly FesteUhr _uhr = new(new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero));
public ManagerBackupsTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
_settings = new ManagerSettingsStore(_root);
_settings.Settings.LicenseKey = Lizenz;
}
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
private string Instanz(string name, bool automatisch = true, string uhrzeit = "03:00", int behalten = 14)
{
var pfad = _verzeichnisse.CreateInstance(name);
var datei = Path.Combine(pfad, "InstanceSettings.json");
var config = JsonSerializer.Deserialize(File.ReadAllText(datei))!;
config.AutoBackupEnabled = automatisch;
config.AutoBackupTime = uhrzeit;
config.BackupKeepCount = behalten;
File.WriteAllText(datei, JsonSerializer.Serialize(config));
return pfad;
}
private async Task DienstAsync(IBackupUploader? upload = null)
{
var register = new InstanceRegistry(
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
SupervisionOptions.Default);
await register.RefreshAsync();
return new ManagerBackups(
_settings, register, upload ?? _upload, NullLogger.Instance, _uhr);
}
private static string[] Sicherungen(string pfad)
{
var ordner = Path.Combine(pfad, "Backups");
return Directory.Exists(ordner) ? Directory.GetFiles(ordner) : [];
}
// ─── Namen ───
///
/// Ein Präfix ist keine Grenze: Die Archive von Test_2 gehören nicht zu
/// Test — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg.
///
[Fact]
public void Ineinanderliegende_Instanznamen_werden_nicht_verwechselt()
{
var eigene = BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 11, 3, 0, 0));
var fremde = BackupNaming.FileNameFor("Test_2", new DateTime(2026, 9, 11, 3, 0, 0));
BackupNaming.BelongsTo(eigene, "Test").ShouldBeTrue();
BackupNaming.BelongsTo(fremde, "Test").ShouldBeFalse();
BackupNaming.BelongsTo(fremde, "Test_2").ShouldBeTrue();
}
[Theory]
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // taegliche Sicherung der Instanz
[InlineData("backup_Alpha_20260911_030000.zip")] // erste Verwaltungsansicht
[InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager
public void Aeltere_Schreibweisen_werden_erkannt(string name)
{
BackupNaming.TimestampOf(name, "Alpha").ShouldBe(new DateTime(2026, 9, 11, 3, 0, 0));
}
[Theory]
[InlineData("notizen.txt")]
[InlineData("backup_Alpha_kaputt.zip")]
[InlineData("backup_Alpha_2026-09-11_0300.zip.part")]
public void Fremde_Dateien_gehoeren_niemandem(string name)
{
BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse();
}
// ─── Sichern ───
[Fact]
public async Task Ohne_Lizenzschluessel_wird_keine_Sicherung_angelegt()
{
var pfad = Instanz("Alpha");
_settings.Settings.LicenseKey = "";
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
await Should.ThrowAsync(() =>
dienst.CreateAsync(instanz, false, true, CancellationToken.None));
Sicherungen(pfad).ShouldBeEmpty("Unverschlüsselt wird nichts abgelegt — auch nicht ersatzweise.");
}
[Fact]
public async Task Eine_Sicherung_ist_verschluesselt_und_wird_hochgeladen()
{
var pfad = Instanz("Alpha");
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
LicenseBackupCipher.IsEncrypted(ergebnis.Path).ShouldBeTrue();
LicenseBackupCipher.MatchesKey(ergebnis.Path, Lizenz).ShouldBeTrue();
ergebnis.Uploaded.ShouldBeTrue();
_upload.Hochgeladen.ShouldBe([ergebnis.Path]);
}
///
/// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für
/// misslungen zu erklären — sie liegt ja lokal.
///
[Fact]
public async Task Ein_gescheiterter_Upload_laesst_die_Sicherung_stehen()
{
Instanz("Alpha");
_upload.Fehler = new IOException("Server nicht erreichbar");
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
File.Exists(ergebnis.Path).ShouldBeTrue();
ergebnis.Uploaded.ShouldBeFalse();
ergebnis.UploadProblem.ShouldBe("Server nicht erreichbar");
}
[Fact]
public async Task Aufgeraeumt_wird_nur_die_eigene_Instanz()
{
var ordner = Path.Combine(_root, "ablage");
Directory.CreateDirectory(ordner);
for (var tag = 1; tag <= 5; tag++)
{
var zeit = new DateTime(2026, 9, tag, 3, 0, 0);
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test", zeit)), "");
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test_2", zeit)), "");
}
await using var dienst = await DienstAsync();
dienst.Rotate(ordner, "Test", keep: 2);
var namen = Directory.GetFiles(ordner).Select(Path.GetFileName).ToList();
namen.Count(n => BackupNaming.BelongsTo(n!, "Test")).ShouldBe(2);
namen.Count(n => BackupNaming.BelongsTo(n!, "Test_2")).ShouldBe(5, "Die andere Instanz bleibt unberührt.");
namen.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0)));
}
// ─── Der tägliche Takt ───
[Fact]
public async Task Vor_der_Uhrzeit_passiert_nichts()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
await using var dienst = await DienstAsync();
await dienst.TickAsync(); // 02:00
Sicherungen(pfad).ShouldBeEmpty();
}
[Fact]
public async Task Einmal_am_Tag_und_nicht_oefter()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
await using var dienst = await DienstAsync();
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5); // 03:30
await dienst.TickAsync();
_uhr.Jetzt = _uhr.Jetzt.AddMinutes(30); // 04:00
await dienst.TickAsync();
Sicherungen(pfad).Length.ShouldBe(1);
}
///
/// Auch über einen Neustart des Managers hinweg. Nur im Speicher zu zählen hieße: Wer
/// nach der Sicherungszeit neu startet, bekommt eine zweite Sicherung.
///
[Fact]
public async Task Ein_Neustart_des_Managers_erzeugt_keine_zweite_Sicherung()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5);
await using (var erster = await DienstAsync())
await erster.TickAsync();
_uhr.Jetzt = _uhr.Jetzt.AddHours(1);
await using (var zweiter = await DienstAsync())
await zweiter.TickAsync();
Sicherungen(pfad).Length.ShouldBe(1);
}
[Fact]
public async Task Ohne_Schalter_keine_automatische_Sicherung()
{
var pfad = Instanz("Alpha", automatisch: false);
_uhr.Jetzt = _uhr.Jetzt.AddHours(5);
await using var dienst = await DienstAsync();
await dienst.TickAsync();
Sicherungen(pfad).ShouldBeEmpty();
}
// ─── Hilfen ───
private async Task> RegisterAsync()
{
var register = new InstanceRegistry(
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
SupervisionOptions.Default);
await register.RefreshAsync();
return register.Instances;
}
private sealed class FakeUploader : IBackupUploader
{
public List Hochgeladen { get; } = [];
public Exception? Fehler { get; set; }
public bool IsConfigured => true;
public Task UploadAsync(string localPath, string instanceName, int keepCount, CancellationToken ct)
{
if (Fehler is not null) throw Fehler;
Hochgeladen.Add(localPath);
return Task.CompletedTask;
}
public Task TestAsync(CancellationToken ct) => Task.FromResult(null);
}
/// Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt.
private sealed class FesteUhr(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Jetzt { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Jetzt;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}