using System.Net; using System.Net.Http.Json; using Flaro.Api; using Flaro.Api.Endpoints; using Flaro.Core.Delivery; namespace Flaro.Api.Tests; /// Was der Server auf dem Kratzplatz wegräumt — und was nicht. /// /// /// /// Der Anlass: 39 Gigabyte. Für jede Mitnahme baut der Server eine /// MP4 in Quellgrösse und löschte sie nie. Stromordner verschwanden nur /// beim geordneten Herunterfahren, Untertitel gar nicht. /// /// /// Hier wird die Verdrahtung geprüft, nicht die Regel — die steht /// ohne Platte und ohne Server in /// Flaro.Core.Tests/KratzplatzregelTests. Was hier zählt: findet der /// Aufräumer die Dateien, erkennt er, was zur Mediathek gehört, und lässt /// er das Gültige stehen. /// /// [TestFixture] public sealed class KratzplatzTests { private string _bestand = ""; /// Ein fester Änderungszeitpunkt, damit der Name vorhersagbar ist. private static readonly DateTimeOffset Gestempelt = new(2026, 3, 1, 10, 0, 0, TimeSpan.Zero); [SetUp] public void Anlegen() => _bestand = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), "flaro-kratz-" + Guid.CreateVersion7().ToString("N"))).FullName; [TearDown] public void Aufraeumen() { try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ } } /// Ein Server mit einem eingelesenen Film und bekanntem Kratzplatz. private async Task<(HttpClient Client, string Kratzplatz, Guid DateiId)> Aufgesetzt(FlaroServer server) { var voll = Path.Combine(_bestand, "Probe (2026)", "Probe (2026).mkv"); Directory.CreateDirectory(Path.GetDirectoryName(voll)!); await File.WriteAllBytesAsync(voll, new byte[4096]); // **Der Zeitstempel wird gesetzt, nicht abgelesen.** Der Name einer // umgepackten Datei trägt ihn; ohne festen Wert liesse sich kein // gültiger Name bauen, und der Test prüfte nur das Löschen. File.SetLastWriteTimeUtc(voll, Gestempelt.UtcDateTime); var admin = await server.SetUpAndSignIn(); var client = server.AlsAngemeldeter(admin.Token); var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", new CreateLibraryRequest("Prüfbestand", "Movies", _bestand)); angelegt.EnsureSuccessStatusCode(); var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); for (var versuch = 0; versuch < 100; versuch++) { var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); if (stand!.State is "finished") break; if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); await Task.Delay(50); } var orte = await client.GetFromJsonAsync>("/api/v1/system/storage"); var kratz = orte!.First(o => o.Variable == "FLARO_TRANSCODE").Pfad; var filme = await client.GetFromJsonAsync>("/api/v1/movies"); var film = await client.GetFromJsonAsync($"/api/v1/movies/{filme!.Items[0].Id}"); return (client, kratz, film!.Files[0].Id); } private static string Legen(string ordner, string name, int kilobyte = 4) { Directory.CreateDirectory(ordner); var pfad = Path.Combine(ordner, name); File.WriteAllBytes(pfad, new byte[kilobyte * 1024]); return pfad; } private static Task Aufraeumen(HttpClient client) => client.PostAsync("/api/v1/system/storage/transcode/cleanup", null) .ContinueWith(a => a.Result.Content.ReadFromJsonAsync()).Unwrap(); /// Ein Paket, zu dem es keinen Eintrag mehr gibt, fliegt. [Test] public async Task Ein_verwaistes_Paket_ist_nach_dem_Aufraeumen_weg() { using var server = new FlaroServer(); var (client, kratz, _) = await Aufgesetzt(server); var fremd = Legen(Path.Combine(kratz, "packages"), Paketname.Bauen(Guid.CreateVersion7(), Gestempelt, PackageCoordinator.Bauart)); var bericht = await Aufraeumen(client); Assert.Multiple(() => { Assert.That(File.Exists(fremd), Is.False); Assert.That(bericht!.Frei, Is.GreaterThan(0), "und es wird gemeldet, was freigeworden ist"); }); } /// Ein Paket, das noch gilt, bleibt liegen. /// /// /// Die wichtigere Hälfte. Ein Aufräumer, der zu viel wegwirft, /// kostet bei jedem zweiten Abspielen die volle Umpackzeit — und das /// merkt man erst im Betrieb. /// [Test] public async Task Ein_gueltiges_Paket_bleibt_liegen() { using var server = new FlaroServer(); var (client, kratz, dateiId) = await Aufgesetzt(server); var gueltig = Legen(Path.Combine(kratz, "packages"), Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart)); await Aufraeumen(client); Assert.That(File.Exists(gueltig), Is.True); } /// Ein Paket zum alten Stand derselben Datei fliegt. [Test] public async Task Ein_Paket_mit_altem_Zeitstempel_ist_weg() { using var server = new FlaroServer(); var (client, kratz, dateiId) = await Aufgesetzt(server); var veraltet = Legen(Path.Combine(kratz, "packages"), Paketname.Bauen(dateiId, Gestempelt.AddDays(-5), PackageCoordinator.Bauart)); await Aufraeumen(client); Assert.That(File.Exists(veraltet), Is.False); } /// Was eine frühere Fassung des Servers gebaut hat, fliegt. [Test] public async Task Ein_Paket_fremder_Bauart_ist_weg() { using var server = new FlaroServer(); var (client, kratz, dateiId) = await Aufgesetzt(server); var alteBauart = Legen(Path.Combine(kratz, "packages"), Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart - 1)); await Aufraeumen(client); Assert.That(File.Exists(alteBauart), Is.False); } /// Ein liegengebliebenes Teilstück fliegt bedingungslos. /// /// /// Es entsteht nur beim Bauen; wer gerade baut, hält die Datei fest. Ein /// liegengebliebenes ist das Überbleibsel eines harten Abbruchs — und /// genau dafür gibt es die Endung. /// [Test] public async Task Ein_Teilstueck_ist_weg() { using var server = new FlaroServer(); var (client, kratz, dateiId) = await Aufgesetzt(server); var teil = Legen(Path.Combine(kratz, "packages"), Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart) + ".teil"); await Aufraeumen(client); Assert.That(File.Exists(teil), Is.False); } /// Ein verwaister Stromordner ist nach dem Aufräumen weg. [Test] public async Task Ein_verwaister_Stromordner_ist_weg() { using var server = new FlaroServer(); var (client, kratz, _) = await Aufgesetzt(server); var ordner = Path.Combine(kratz, "stroeme", Guid.CreateVersion7().ToString()); Legen(ordner, "000.m4s"); // Älter als die Geduld, sonst gilt er als eben erst beschrieben. Directory.SetLastWriteTimeUtc(ordner, DateTime.UtcNow.AddHours(-1)); await Aufraeumen(client); Assert.That(Directory.Exists(ordner), Is.False); } /// Ein eben beschriebener Stromordner bleibt. /// /// /// Die Sitzung lebt im Arbeitsspeicher; dass ein Ordner gerade Segmente /// bekommt, sieht man nur an seiner Änderungszeit. Ohne diese Prüfung /// risse ein Aufräumlauf einer laufenden Wiedergabe den Boden weg. /// [Test] public async Task Ein_frischer_Stromordner_bleibt() { using var server = new FlaroServer(); var (client, kratz, _) = await Aufgesetzt(server); var ordner = Path.Combine(kratz, "stroeme", Guid.CreateVersion7().ToString()); Legen(ordner, "000.m4s"); await Aufraeumen(client); Assert.That(Directory.Exists(ordner), Is.True); } /// Eine Untertiteldatei zu einem gelöschten Titel fliegt. [Test] public async Task Eine_verwaiste_Untertiteldatei_ist_weg() { using var server = new FlaroServer(); var (client, kratz, _) = await Aufgesetzt(server); var weg = Legen(Path.Combine(kratz, "untertitel"), $"{Guid.CreateVersion7():N}-{Gestempelt.ToUnixTimeMilliseconds()}-2.vtt"); await Aufraeumen(client); Assert.That(File.Exists(weg), Is.False); } /// Die Übersicht nennt die drei Unterordner mit Grösse und Anzahl. [Test] public async Task Die_Belegung_nennt_die_drei_Unterordner() { using var server = new FlaroServer(); var (client, kratz, dateiId) = await Aufgesetzt(server); Legen(Path.Combine(kratz, "packages"), Paketname.Bauen(dateiId, Gestempelt, PackageCoordinator.Bauart), kilobyte: 8); var orte = await client.GetFromJsonAsync>("/api/v1/system/storage"); var kratzplatz = orte!.First(o => o.Variable == "FLARO_TRANSCODE"); Assert.Multiple(() => { Assert.That(kratzplatz.Teile, Is.Not.Null); Assert.That(kratzplatz.Teile!.Select(t => t.Name), Is.EquivalentTo(new[] { "stroeme", "packages", "untertitel" })); var pakete = kratzplatz.Teile!.First(t => t.Name == "packages"); Assert.That(pakete.Dateien, Is.EqualTo(1)); Assert.That(pakete.Frei, Is.EqualTo(8 * 1024), "das Feld Frei trägt hier die Belegung des Ordners"); Assert.That(orte!.First(o => o.Variable == "FLARO_DATA").Teile, Is.Null, "nur der Kratzplatz wird aufgeschlüsselt"); }); } [Test] public async Task Aufraeumen_verlangt_Administratorrechte() { using var server = new FlaroServer(); var (admin, _, _) = await Aufgesetzt(server); var gast = await server.NeuerBenutzer(admin, "gast", "gastpasswortlang"); var antwort = await gast.PostAsync("/api/v1/system/storage/transcode/cleanup", null); Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); } /* ---------- Die Einstellungen ---------- */ [Test] public async Task Die_Vorgaben_stehen_ohne_dass_jemand_etwas_gesetzt_hat() { using var server = new FlaroServer(); var (client, _, _) = await Aufgesetzt(server); var stand = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); Assert.Multiple(() => { Assert.That(stand!.CapGb, Is.EqualTo(20).Within(0.01)); Assert.That(stand.UnusedDays, Is.EqualTo(7)); Assert.That(stand.GraceMinutes, Is.EqualTo(30)); }); } [Test] public async Task Eine_gesetzte_Obergrenze_kommt_zurueck() { using var server = new FlaroServer(); var (client, _, _) = await Aufgesetzt(server); await client.PutAsJsonAsync("/api/v1/settings/maintenance", new MaintenanceSettingsRequest(5, null, null)); var stand = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); Assert.Multiple(() => { Assert.That(stand!.CapGb, Is.EqualTo(5).Within(0.01)); Assert.That(stand.UnusedDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt"); }); } [Test] public async Task Eine_unsinnige_Schonfrist_wird_zurechtgebogen() { using var server = new FlaroServer(); var (client, _, _) = await Aufgesetzt(server); await client.PutAsJsonAsync("/api/v1/settings/maintenance", new MaintenanceSettingsRequest(null, null, 1)); var stand = await client.GetFromJsonAsync("/api/v1/settings/maintenance"); Assert.That(stand!.GraceMinutes, Is.EqualTo(5), "eine Minute löschte die Datei mitten im Herunterladen weg"); } }