Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im Quelltext stehen. Version und Build. Die Version steht fest; daneben eine Kennung aus sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist "reden wir vom selben Build?" in zwei Sekunden beantwortet statt in einem halben Tag. Serien. Lag eine Datei direkt im Serienordner und trug vor der Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95 Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab zwei Serien. Und weder neue noch umgezogene Dateien standen in der Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite Zeile mit gleichem (LibraryId, Path) anlegte und der Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede Serienanlage wird jetzt im Tagesprotokoll begründet. Fehlermeldungen. "An error occurred while saving the entity changes" stand englisch in einer deutschen Oberfläche und nannte weder Ursache noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für "Failed to fetch", das aus dem Browser kam und nie übersetzt wurde. Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt serverseitig rückwärts, mit einem Knopf für mehr. Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein eigenes Listenfeld mit Tastaturbedienung. Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp. Sprache. Die Serversprache stand in der Serverliste zwischen Name, Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen eigenen Abschnitt ganz oben. Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden, ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer Zuordnung frischt nur noch die Kachel auf statt die ganze Seite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
6.9 KiB
C#
183 lines
6.9 KiB
C#
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Was beim allerersten Start passiert, bevor irgendetwas anderes greift.</summary>
|
|
public class FlaroPathsTests
|
|
{
|
|
private static readonly string[] Variablen = ["FLARO_DATA", "FLARO_CACHE", "FLARO_TRANSCODE"];
|
|
|
|
private string _wurzel = "";
|
|
private Dictionary<string, string?> _vorher = [];
|
|
|
|
/// <summary>Merkt sich die Umgebung, bevor diese Klasse daran rührt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Ohne das kostete der ganze Lauf eine Viertelstunde.</b> Zwei Tests
|
|
/// hier löschen die Pfadvariablen, um den Rückfall auf die Voreinstellung
|
|
/// zu prüfen — und liessen sie gelöscht zurück. Jeder danach gestartete
|
|
/// Testserver fiel dann auf <c><Projektverzeichnis>/data</c> zurück,
|
|
/// und Dutzende Server teilten sich **eine** SQLite-Datei. Die warteten
|
|
/// dann aufeinander, statt zu prüfen.
|
|
///
|
|
/// Prozessweiter Zustand gehört zurückgesetzt, wo er verändert wurde. Das
|
|
/// ist keine Formsache: der Schaden zeigt sich erst in einer anderen Klasse.
|
|
/// </remarks>
|
|
[SetUp]
|
|
public void Anlegen()
|
|
{
|
|
_vorher = Variablen.ToDictionary(v => v, Environment.GetEnvironmentVariable);
|
|
_wurzel = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "flaro-pfade-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
|
}
|
|
|
|
[TearDown]
|
|
public void Aufraeumen()
|
|
{
|
|
// Zurücksetzen, sonst lässt sich das gesperrte Verzeichnis nicht löschen.
|
|
if (!OperatingSystem.IsWindows())
|
|
{
|
|
foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories))
|
|
File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
|
}
|
|
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
|
|
|
|
foreach (var (name, wert) in _vorher) Environment.SetEnvironmentVariable(name, wert);
|
|
}
|
|
|
|
private FlaroPaths Pfade(string? daten = null) => new()
|
|
{
|
|
Data = daten ?? Path.Combine(_wurzel, "config"),
|
|
Cache = Path.Combine(_wurzel, "cache"),
|
|
Transcode = Path.Combine(_wurzel, "transcode"),
|
|
};
|
|
|
|
[Test]
|
|
public void Fehlende_Verzeichnisse_werden_angelegt()
|
|
{
|
|
var pfade = Pfade();
|
|
pfade.EnsureExists();
|
|
|
|
Assert.That(Directory.Exists(pfade.Data), Is.True);
|
|
Assert.That(Directory.Exists(pfade.Cache), Is.True);
|
|
Assert.That(Directory.Exists(pfade.Transcode), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void Ein_zweiter_Start_stoert_sich_nicht_am_ersten()
|
|
{
|
|
var pfade = Pfade();
|
|
pfade.EnsureExists();
|
|
Assert.DoesNotThrow(() => pfade.EnsureExists());
|
|
}
|
|
|
|
/// <summary>Der häufigste Stolperstein beim ersten Containerstart.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Hier wird die Diagnose geprüft, nicht die Funktion.</b> Ein
|
|
/// vorhandenes, aber gesperrtes Verzeichnis lässt
|
|
/// <c>Directory.CreateDirectory</c> durchgehen; ohne eigene Schreibprobe
|
|
/// stirbt der Server erst später an „unable to open database file" — einer
|
|
/// Meldung, die weder das Verzeichnis nennt noch verrät, dass es um
|
|
/// Dateirechte geht.
|
|
/// </remarks>
|
|
[Test]
|
|
public void Ein_gesperrtes_Verzeichnis_faellt_beim_Start_auf_und_nennt_den_Grund()
|
|
{
|
|
if (OperatingSystem.IsWindows()) Assert.Ignore("Dateirechte verhalten sich dort anders.");
|
|
|
|
var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "gesperrt")).FullName;
|
|
if (OperatingSystem.IsWindows()) return;
|
|
File.SetUnixFileMode(gesperrt, UnixFileMode.UserRead | UnixFileMode.UserExecute);
|
|
|
|
var fehler = Assert.Throws<InvalidOperationException>(() => Pfade(daten: gesperrt).EnsureExists());
|
|
|
|
Assert.That(fehler!.Message, Does.Contain("FLARO_DATA"), "Welche Einstellung gemeint ist");
|
|
Assert.That(fehler.Message, Does.Contain(gesperrt), "Welches Verzeichnis");
|
|
Assert.That(fehler.Message, Does.Contain("uid="), "Als wer der Server läuft");
|
|
Assert.That(fehler.Message, Does.Contain("chown"), "Was zu tun ist");
|
|
}
|
|
|
|
[Test]
|
|
public void Ohne_Umgebungsvariablen_liegt_alles_neben_dem_Programm()
|
|
{
|
|
foreach (var name in new[] { "FLARO_DATA", "FLARO_CACHE", "FLARO_TRANSCODE" })
|
|
Environment.SetEnvironmentVariable(name, null);
|
|
|
|
var pfade = FlaroPaths.FromEnvironment(_wurzel);
|
|
|
|
Assert.That(pfade.Data, Is.EqualTo(Path.Combine(_wurzel, "data")));
|
|
Assert.That(pfade.DatabaseFile, Does.EndWith("flaro.db"));
|
|
}
|
|
|
|
[Test]
|
|
public void Die_Umgebung_sticht_die_Voreinstellung()
|
|
{
|
|
Environment.SetEnvironmentVariable("FLARO_DATA", "/woanders");
|
|
Assert.That(FlaroPaths.FromEnvironment(_wurzel).Data, Is.EqualTo("/woanders"));
|
|
// Zurückgesetzt wird im TearDown, für alle drei Variablen gemeinsam.
|
|
}
|
|
}
|
|
|
|
/// <summary>Das Ende eines Protokolls lesen, ohne die ganze Datei zu holen.</summary>
|
|
public class TagebuchEndeTests
|
|
{
|
|
private string _wurzel = "";
|
|
private Tagebuch _tagebuch = null!;
|
|
|
|
[SetUp]
|
|
public void Anlegen()
|
|
{
|
|
_wurzel = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "flaro-log-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
|
|
|
_tagebuch = new Tagebuch(
|
|
new FlaroPaths { Data = _wurzel, Cache = _wurzel, Transcode = _wurzel },
|
|
TimeProvider.System);
|
|
}
|
|
|
|
[TearDown]
|
|
public void Aufraeumen()
|
|
{
|
|
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
[Test]
|
|
public void Liefert_die_letzten_Zeilen()
|
|
{
|
|
var heute = DateOnly.FromDateTime(DateTime.Now);
|
|
for (var i = 1; i <= 5000; i++) _tagebuch.Schreiben($"Zeile {i}");
|
|
|
|
var ende = _tagebuch.Ende(heute, 10).TrimEnd('\n').Split('\n');
|
|
|
|
Assert.That(ende, Has.Length.EqualTo(10));
|
|
Assert.That(ende[^1], Does.Contain("Zeile 5000"));
|
|
Assert.That(ende[0], Does.Contain("Zeile 4991"));
|
|
}
|
|
|
|
/// <summary>Die Ausgabe beginnt nie mitten in einer Zeile.</summary>
|
|
[Test]
|
|
public void Faengt_an_einem_Zeilenanfang_an()
|
|
{
|
|
var heute = DateOnly.FromDateTime(DateTime.Now);
|
|
for (var i = 1; i <= 3000; i++) _tagebuch.Schreiben(new string('x', 300) + $" {i}");
|
|
|
|
var ende = _tagebuch.Ende(heute, 5).Split('\n')[0];
|
|
|
|
Assert.That(ende, Does.Match(@"^\d{2}:\d{2}:\d{2} "),
|
|
"Sonst beginnt die Anzeige mitten im Satz.");
|
|
}
|
|
|
|
/// <summary>Weniger Zeilen als verlangt ist kein Fehler.</summary>
|
|
[Test]
|
|
public void Eine_kurze_Datei_kommt_ganz()
|
|
{
|
|
var heute = DateOnly.FromDateTime(DateTime.Now);
|
|
_tagebuch.Schreiben("Eine einzige Zeile");
|
|
|
|
Assert.That(_tagebuch.Ende(heute, 800), Does.Contain("Eine einzige Zeile"));
|
|
}
|
|
|
|
[Test]
|
|
public void Ein_Tag_ohne_Protokoll_ist_leer() =>
|
|
Assert.That(_tagebuch.Ende(new DateOnly(2000, 1, 1), 10), Is.Empty);
|
|
}
|