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 NewInstance(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 FixedClock(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 now) => new(dir, config, NullLogger.Instance, new FixedClock(now)); private static DateTime AtNight(int tag) => new(2026, 9, tag, 4, 0, 0, DateTimeKind.Utc); // ─── The target folder ─── [Fact] public async Task Without_value_the_backup_ends_up_in_the_instance_directory() { var dir = NewInstance("Allein"); await Scheduler(dir, Config("Allein"), AtNight(1)).TickAsync(); var ziel = Path.Combine(dir, "Backups"); Directory.Exists(ziel).ShouldBeTrue(); Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(1); } [Fact] public async Task Two_instances_without_value_back_up_separately() { var a = NewInstance("Eins"); var b = NewInstance("Zwei"); await Scheduler(a, Config("Eins"), AtNight(1)).TickAsync(); await Scheduler(b, Config("Zwei"), AtNight(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 Switched_off_nothing_happens() { var dir = NewInstance("Ruhig"); var config = Config("Ruhig"); config.AutoBackupEnabled = false; await Scheduler(dir, config, AtNight(1)).TickAsync(); Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse(); } [Fact] public async Task Before_the_configured_time_nothing_happens() { var dir = NewInstance("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 Twice_on_the_same_day_backs_up_only_once() { var dir = NewInstance("Einmal"); var scheduler = Scheduler(dir, Config("Einmal"), AtNight(1)); await scheduler.TickAsync(); await scheduler.TickAsync(); Directory.GetFiles(Path.Combine(dir, "Backups"), "*.zip").Length.ShouldBe(1); } // ─── Retention ─── [Fact] public async Task Retention_cleans_up_its_own_archives() { var dir = NewInstance("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), AtNight(1)).TickAsync(); Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2); } [Fact] public async Task Retention_zero_keeps_everything() { var dir = NewInstance("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), AtNight(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 In_the_shared_folder_retention_reaches_beyond_the_instance() { var shared = Path.Combine(_root, "Backups"); Directory.CreateDirectory(shared); var foreign = Path.Combine(shared, "backup_Test_2_2026-08-01_0300.zip"); File.WriteAllText(foreign, "gehoert Test_2"); File.SetLastWriteTime(foreign, new DateTime(2026, 8, 1, 3, 0, 0)); var dir = NewInstance("Test"); await Scheduler(dir, Config("Test", shared, keep: 1), AtNight(1)).TickAsync(); File.Exists(foreign).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 own = NewInstance("Test2"); var ownArchive = Path.Combine(own, "Backups", "backup_Test2_2026-08-01_0300.zip"); Directory.CreateDirectory(Path.GetDirectoryName(ownArchive)!); File.WriteAllText(ownArchive, "gehoert Test2"); var dir2 = NewInstance("Test3"); await Scheduler(dir2, Config("Test3", keep: 1), AtNight(2)).TickAsync(); File.Exists(ownArchive).ShouldBeTrue(); } }