using ClawdDotNet.App.Services; using ClawdDotNet.Core.Config; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.App.Tests.Services; /// /// Die tägliche Sicherung, seit sie der Instanz gehört (#528, Stufe 1). /// /// Schalter, Uhrzeit, Zielordner und Aufbewahrung standen in den /// Anwendungseinstellungen und galten damit für jede Instanz. Zwei Folgen: Wer die /// Sicherung für eine Instanz einschaltete, schaltete sie für alle ein — und alle /// Archive lagen in einem Ordner, in dem die Aufbewahrung nach dem Präfix /// backup_<Instanzname>_ aufräumt. Dass ein Präfix keine Grenze ist, zeigt /// . /// public sealed class BackupSchedulerTests : IDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]); public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } /// Ein Instanzverzeichnis mit genug Inhalt, dass sich eine Sicherung lohnt. private string NeueInstanz(string name) { var dir = Path.Combine(_root, "Instance-" + name); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, "InstanceSettings.json"), $$"""{"instanceName":"{{name}}"}"""); return dir; } private static InstanceConfig Config(string name, string? backupDirectory = null, int keep = 14) => new() { InstanceName = name, WorkingDirectory = "", AutoBackupEnabled = true, AutoBackupTime = "03:00", BackupDirectory = backupDirectory ?? "", BackupKeepCount = keep }; /// /// Eine Uhr, die auf einer Zeit steht — der Zeitpunkt entscheidet, ob /// TickAsync etwas tut. /// private sealed class FesteUhr(DateTime jetzt) : TimeProvider { public override DateTimeOffset GetUtcNow() => new(jetzt, TimeSpan.Zero); public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; } private static BackupScheduler Scheduler(string dir, InstanceConfig config, DateTime jetzt) => new(dir, config, NullLogger.Instance, new FesteUhr(jetzt)); private static DateTime Nachts(int tag) => new(2026, 9, tag, 4, 0, 0, DateTimeKind.Utc); // ─── Der Zielordner ─── [Fact] public async Task Ohne_Angabe_landet_die_Sicherung_im_Instanzverzeichnis() { var dir = NeueInstanz("Allein"); await Scheduler(dir, Config("Allein"), Nachts(1)).TickAsync(); var ziel = Path.Combine(dir, "Backups"); Directory.Exists(ziel).ShouldBeTrue(); Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(1); } [Fact] public async Task Zwei_Instanzen_sichern_ohne_Angabe_getrennt() { var a = NeueInstanz("Eins"); var b = NeueInstanz("Zwei"); await Scheduler(a, Config("Eins"), Nachts(1)).TickAsync(); await Scheduler(b, Config("Zwei"), Nachts(1)).TickAsync(); Directory.GetFiles(Path.Combine(a, "Backups"), "*.zip").Length.ShouldBe(1); Directory.GetFiles(Path.Combine(b, "Backups"), "*.zip").Length.ShouldBe(1); } // ─── Der Schalter ─── [Fact] public async Task Ausgeschaltet_passiert_nichts() { var dir = NeueInstanz("Ruhig"); var config = Config("Ruhig"); config.AutoBackupEnabled = false; await Scheduler(dir, config, Nachts(1)).TickAsync(); Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse(); } [Fact] public async Task Vor_der_eingestellten_Uhrzeit_passiert_nichts() { var dir = NeueInstanz("Frueh"); await Scheduler(dir, Config("Frueh"), new DateTime(2026, 9, 1, 2, 0, 0, DateTimeKind.Utc)) .TickAsync(); Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse(); } [Fact] public async Task Zweimal_am_selben_Tag_sichert_nur_einmal() { var dir = NeueInstanz("Einmal"); var scheduler = Scheduler(dir, Config("Einmal"), Nachts(1)); await scheduler.TickAsync(); await scheduler.TickAsync(); Directory.GetFiles(Path.Combine(dir, "Backups"), "*.zip").Length.ShouldBe(1); } // ─── Die Aufbewahrung ─── [Fact] public async Task Die_Aufbewahrung_raeumt_die_eigenen_Archive_auf() { var dir = NeueInstanz("Alt"); var ziel = Path.Combine(dir, "Backups"); Directory.CreateDirectory(ziel); // Drei Archive aus der Vergangenheit, die aelter sind als das gleich entstehende. foreach (var tag in new[] { 1, 2, 3 }) { var datei = Path.Combine(ziel, $"backup_Alt_2026-08-0{tag}_0300.zip"); File.WriteAllText(datei, "alt"); File.SetLastWriteTime(datei, new DateTime(2026, 8, tag, 3, 0, 0)); } await Scheduler(dir, Config("Alt", keep: 2), Nachts(1)).TickAsync(); Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2); } [Fact] public async Task Aufbewahrung_null_behaelt_alles() { var dir = NeueInstanz("Sammler"); var ziel = Path.Combine(dir, "Backups"); Directory.CreateDirectory(ziel); File.WriteAllText(Path.Combine(ziel, "backup_Sammler_2026-08-01_0300.zip"), "alt"); await Scheduler(dir, Config("Sammler", keep: 0), Nachts(1)).TickAsync(); Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2); } /// /// Warum der Zielordner der Instanz gehören muss. /// /// Die Aufbewahrung sucht nach dem Präfix backup_<Instanzname>_, /// und ein Präfix ist keine Grenze: backup_Test_2_….zip — das Archiv der /// Instanz Test_2 — fängt mit backup_Test_ an. Im gemeinsamen Ordner /// zählt Test es also zu den eigenen und wirft es weg. Getrennte Ordner /// nehmen der Namensfrage ihre Wirkung: Der Test hier stellt den alten Zustand /// eigens wieder her, indem er beiden Instanzen denselben Ordner einträgt. /// [Fact] public async Task Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus() { var gemeinsam = Path.Combine(_root, "Backups"); Directory.CreateDirectory(gemeinsam); var fremd = Path.Combine(gemeinsam, "backup_Test_2_2026-08-01_0300.zip"); File.WriteAllText(fremd, "gehoert Test_2"); File.SetLastWriteTime(fremd, new DateTime(2026, 8, 1, 3, 0, 0)); var dir = NeueInstanz("Test"); await Scheduler(dir, Config("Test", gemeinsam, keep: 1), Nachts(1)).TickAsync(); File.Exists(fremd).ShouldBeFalse( "Der alte Zustand ist damit belegt — im gemeinsamen Ordner räumt eine " + "Instanz über ihre eigenen Archive hinaus auf."); // Und die Gegenprobe: mit getrennten Ordnern kommen sie sich nicht in die Quere. var eigen = NeueInstanz("Test2"); var eigenesArchiv = Path.Combine(eigen, "Backups", "backup_Test2_2026-08-01_0300.zip"); Directory.CreateDirectory(Path.GetDirectoryName(eigenesArchiv)!); File.WriteAllText(eigenesArchiv, "gehoert Test2"); var dir2 = NeueInstanz("Test3"); await Scheduler(dir2, Config("Test3", keep: 1), Nachts(2)).TickAsync(); File.Exists(eigenesArchiv).ShouldBeTrue(); } }