Files
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
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>
2026-10-04 13:18:43 +02:00

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_&lt;Instanzname&gt;_</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_&lt;Instanzname&gt;_</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();
}
}