Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
7.4 KiB
C#
202 lines
7.4 KiB
C#
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.Core.Config;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.App.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// The daily backup, since it belongs to the instance (#528, stage 1).
|
|
///
|
|
/// <para>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
|
|
/// <c>backup_<Instanzname>_</c>. That a prefix is not a boundary is shown by
|
|
/// <see cref="In_the_shared_folder_retention_reaches_beyond_the_instance"/>.</para>
|
|
/// </summary>
|
|
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. */ }
|
|
}
|
|
|
|
/// <summary>An instance directory with enough content that a backup is worthwhile.</summary>
|
|
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
|
|
};
|
|
|
|
/// <summary>
|
|
/// A clock standing at a time — the point in time decides whether
|
|
/// <c>TickAsync</c> does anything.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Why the target folder has to belong to the instance.
|
|
///
|
|
/// <para>Retention looks for the prefix <c>backup_<Instanzname>_</c>,
|
|
/// and a prefix is not a boundary: <c>backup_Test_2_….zip</c> — the archive of the
|
|
/// instance <c>Test_2</c> — starts with <c>backup_Test_</c>. In the shared folder
|
|
/// <c>Test</c> 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.</para>
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
}
|