using ClawdDotNet.App.Services; using ClawdDotNet.Core.Config; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.App.Tests.Services; /// /// The daily backup, since it belongs to the instance (#528, stage 1). /// /// Switch, time, target folder and retention lived in the /// application settings and thus applied to every instance. Two consequences: whoever /// enabled the backup for one instance enabled it for all — and all /// archives lay in one folder in which retention cleans up by the prefix /// backup_<Instanzname>_. That a prefix is not a boundary is shown by /// . /// 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 { /* A leftover test folder does not turn a run red. */ } } /// An instance directory with enough content that a backup is worthwhile. 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 }; /// /// A clock standing at a time — the point in time decides whether /// TickAsync does anything. /// 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); // ─── The target folder ─── [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); } // ─── The switch ─── [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); } // ─── Retention ─── [Fact] public async Task Die_Aufbewahrung_raeumt_die_eigenen_Archive_auf() { var dir = NeueInstanz("Alt"); var ziel = Path.Combine(dir, "Backups"); Directory.CreateDirectory(ziel); // Three archives from the past that are older than the one about to be created. 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); } /// /// Why the target folder has to belong to the instance. /// /// Retention looks for the prefix backup_<Instanzname>_, /// and a prefix is not a boundary: backup_Test_2_….zip — the archive of the /// instance Test_2 — starts with backup_Test_. In the shared folder /// Test therefore counts it among its own and throws it away. Separate folders /// take away the effect of the naming question: the test here deliberately restores the old state /// by entering the same folder for both instances. /// [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."); // And the counter-check: with separate folders they do not get in each other's way. 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(); } }