Files
Flaro/server/tests/Flaro.Api.Tests/WartungsplanTests.cs
T
ScarriffleandClaude Opus 5 532473b1f9 Ein Plan für das, was an keinem Aufruf hängt
Sichern, Datenbank aufräumen, verwaiste Bilder wegwerfen — alle drei Tage,
alle sieben Tage, von selbst.

**Der erste Hintergrunddienst in diesem Server, und das mit Bedacht.** An
drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne
Gegenwert, und für das Wegräumen abgelaufener Gerätecodes stimmt das:
solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung
hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand etwas
anklickt, und dafür gibt es eine Uhr. Alle fünfzehn Minuten eine Frage an
eine Tabelle mit einer Handvoll Zeilen; ist nichts fällig — der Normalfall
—, kostet es nichts.

Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. Ein
Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — oder,
bei einem Zähler, der bei null anfängt, nie. Auch ein Fehlschlag wird
vermerkt, sonst versuchte der Plan es alle fünfzehn Minuten wieder und
eine volle Platte erzeugte ein volles Protokoll.

**Die Sicherung ist `VACUUM INTO`, nicht Datei kopieren.** Die Datenbank
läuft im WAL-Modus: neben `flaro.db` stehen `-wal` und `-shm`, und wer nur
die erste Datei kopiert, bekommt einen Stand von vor Stunden oder einen
halben. `VACUUM INTO` schreibt eine vollständige, aufgeräumte Kopie im
laufenden Betrieb. Der Zielordner ist einstellbar und geht als gebundener
Wert in die Anweisung — eine Einstellung soll keine SQL-Anweisung werden.

Die Prüfung verlässt sich nicht darauf, dass eine Datei entstanden ist: sie
öffnet die Kopie als Datenbank und zählt die Zeilen. Eine Sicherung, die
sich nicht öffnen lässt, ist schlimmer als keine — man verlässt sich
darauf und merkt es erst, wenn es zu spät ist.

**Bilder nur, wenn verwaist.** Den ganzen Bildspeicher zu leeren gibt es
als Knopf und ist gefahrlos, kostet danach aber tausende Abrufe bei TMDB.
Hier fliegt nur, worauf kein Titel mehr zeigt — mit einer Stunde
Schonfrist, weil ein Bild abgelegt wird, bevor der Verweis darauf
gespeichert ist. Ohne diese Frist räumte ein Lauf mitten im Scan genau das
weg, was gerade geholt wurde.

**Eine eigene Seite „Wartung".** Die drei Kratzplatzzahlen standen unter
„Wiedergabe" — dort ist ihr Anlass, aber nicht ihr Thema. Sie ziehen mit
um: alles auf dieser Seite ist etwas, das der Server ohne Zutun erledigt,
und man sieht es an einem Ort nach. Je Aufgabe steht da, wann sie zuletzt
lief und was herauskam, dazu ein Knopf „Jetzt ausführen" — wer gleich etwas
Grosses vorhat, will die Kopie jetzt und nicht in drei Tagen.

725 Tests grün, davon elf neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:02:42 +02:00

268 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Json;
using Flaro.Api;
using Flaro.Api.Endpoints;
using Microsoft.Data.Sqlite;
namespace Flaro.Api.Tests;
/// <summary>Sichern, aufräumen, Bilder wegwerfen — und was dabei herauskommt.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Sicherung ist das Stück, das im Ernstfall zählt.</b> Eine Kopie,
/// die sich nicht öffnen lässt, ist schlimmer als keine: man verlässt sich
/// darauf und merkt es erst, wenn es zu spät ist. Deshalb prüft der Test
/// nicht, dass eine Datei entstanden ist, sondern dass sie sich als
/// Datenbank öffnen lässt und die Zeilen enthält.
/// </para>
/// <para>
/// <b>Warum nicht einfach die Datei kopieren.</b> Die Datenbank läuft im
/// WAL-Modus: neben <c>flaro.db</c> stehen <c>-wal</c> und <c>-shm</c>, und
/// wer nur die erste kopiert, bekommt einen Stand von vor Stunden oder
/// einen halben. <c>VACUUM INTO</c> schreibt eine vollständige Kopie im
/// laufenden Betrieb — genau das hält dieser Test fest.
/// </para>
/// </remarks>
[TestFixture]
public sealed class WartungsplanTests
{
private static async Task<HttpClient> AlsAdmin(FlaroServer server)
{
var admin = await server.SetUpAndSignIn();
return server.AlsAngemeldeter(admin.Token);
}
private static Task<HttpResponseMessage> Ausfuehren(HttpClient client, string aufgabe) =>
client.PostAsync($"/api/v1/system/maintenance/{aufgabe}", null);
private static async Task<string> Datenordner(HttpClient client)
{
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage");
return orte!.First(o => o.Variable == "FLARO_DATA").Pfad;
}
/// <summary>Die Sicherung ergibt eine Datenbank, die sich öffnen lässt.</summary>
[Test]
public async Task Die_Sicherung_ist_eine_lesbare_Datenbank()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var antwort = await Ausfuehren(client, "sicherung");
antwort.EnsureSuccessStatusCode();
var ordner = Path.Combine(await Datenordner(client), "sicherungen");
var kopien = Directory.GetFiles(ordner, "flaro-*.db");
Assert.That(kopien, Has.Length.EqualTo(1));
// Die Probe aufs Exempel: öffnen und zählen.
await using var verbindung = new SqliteConnection($"Data Source={kopien[0]};Mode=ReadOnly");
await verbindung.OpenAsync();
await using var befehl = verbindung.CreateCommand();
befehl.CommandText = "SELECT COUNT(*) FROM Users";
Assert.That(Convert.ToInt32(await befehl.ExecuteScalarAsync()), Is.EqualTo(1),
"der eingerichtete Administrator muss in der Kopie stehen");
}
/// <summary>Es bleiben so viele Sicherungen liegen, wie eingestellt ist.</summary>
///
/// <remarks>
/// Ohne Grenze füllt eine Sicherung alle drei Tage über ein Jahr
/// hundertzwanzig Kopien derselben Datenbank an — auf demselben Volume,
/// das sie schützen soll.
/// </remarks>
[Test]
public async Task Alte_Sicherungen_werden_weggeraeumt()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupKeep: 2));
var ordner = Path.Combine(await Datenordner(client), "sicherungen");
Directory.CreateDirectory(ordner);
// Drei ältere Stände, die der Lauf überzählig macht.
foreach (var tag in new[] { "2020-01-01-0000", "2020-01-02-0000", "2020-01-03-0000" })
await File.WriteAllTextAsync(Path.Combine(ordner, $"flaro-{tag}.db"), "alt");
(await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode();
Assert.That(Directory.GetFiles(ordner, "flaro-*.db"), Has.Length.EqualTo(2),
"die neue und die neueste alte");
}
/// <summary>Die Sicherung landet im eingestellten Ordner.</summary>
[Test]
public async Task Ein_eigener_Ordner_wird_benutzt()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var eigen = Path.Combine(Path.GetTempPath(), "flaro-sicherung-" + Guid.CreateVersion7().ToString("N"));
try
{
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupFolder: eigen));
(await Ausfuehren(client, "sicherung")).EnsureSuccessStatusCode();
Assert.That(Directory.GetFiles(eigen, "flaro-*.db"), Has.Length.EqualTo(1));
}
finally
{
try { Directory.Delete(eigen, recursive: true); } catch (IOException) { /* Kür */ }
}
}
/// <summary>Das Aufräumen der Datenbank läuft durch und meldet etwas.</summary>
[Test]
public async Task Die_Datenbank_laesst_sich_optimieren()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var antwort = await Ausfuehren(client, "optimieren");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
/// <summary>Ein Bild, auf das nichts zeigt, fliegt — ein benutztes nicht.</summary>
///
/// <remarks>
/// <b>Die Schonfrist ist der Grund für die Zeitstempel.</b> Ein Bild
/// wird abgelegt, bevor der Verweis darauf gespeichert ist; ohne Frist
/// räumte ein Lauf mitten im Scan genau das weg, was gerade geholt wurde.
/// </remarks>
[Test]
public async Task Verwaiste_Bilder_fliegen_frische_bleiben()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var orte = await client.GetFromJsonAsync<List<Speicherort>>("/api/v1/system/storage");
var bilder = Path.Combine(orte!.First(o => o.Variable == "FLARO_CACHE").Pfad, "images", "ab");
Directory.CreateDirectory(bilder);
var alt = Path.Combine(bilder, "abverwaistesbild.img");
var frisch = Path.Combine(bilder, "abfrischesbild.img");
await File.WriteAllBytesAsync(alt, new byte[256]);
await File.WriteAllBytesAsync(frisch, new byte[256]);
File.SetLastWriteTimeUtc(alt, DateTime.UtcNow.AddDays(-1));
(await Ausfuehren(client, "bilder")).EnsureSuccessStatusCode();
Assert.Multiple(() =>
{
Assert.That(File.Exists(alt), Is.False, "auf dieses Bild zeigt nichts mehr");
Assert.That(File.Exists(frisch), Is.True,
"das könnte gerade erst abgelegt und noch nicht verknüpft sein");
});
}
/// <summary>Der Stand nennt jede Aufgabe mit ihrem Abstand.</summary>
[Test]
public async Task Der_Stand_nennt_alle_drei_Aufgaben()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
Assert.Multiple(() =>
{
Assert.That(stand!.Select(s => s.Aufgabe),
Is.EquivalentTo(new[] { "Sicherung", "Optimieren", "Bilder" }));
Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Tage, Is.EqualTo(3));
Assert.That(stand.All(s => s.Zuletzt is null), Is.True, "noch lief keine");
});
}
/// <summary>Nach einem Lauf steht da, wann er war und was herauskam.</summary>
[Test]
public async Task Nach_einem_Lauf_steht_der_Zeitpunkt_fest()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
await Ausfuehren(client, "sicherung");
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
var sicherung = stand!.First(s => s.Aufgabe == "Sicherung");
Assert.Multiple(() =>
{
Assert.That(sicherung.Zuletzt, Is.Not.Null);
Assert.That(sicherung.Faellig, Is.Not.Null, "und wann die nächste ansteht");
Assert.That(sicherung.Ergebnis, Does.Contain("MB"));
});
}
[Test]
public async Task Eine_unbekannte_Aufgabe_wird_abgewiesen()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var antwort = await Ausfuehren(client, "kaffeekochen");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Wartung_verlangt_Administratorrechte()
{
using var server = new FlaroServer();
var admin = await AlsAdmin(server);
var gast = await server.NeuerBenutzer(admin, "gast", "gastpasswortlang");
Assert.Multiple(async () =>
{
Assert.That((await Ausfuehren(gast, "sicherung")).StatusCode,
Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That((await gast.GetAsync("/api/v1/system/maintenance")).StatusCode,
Is.EqualTo(HttpStatusCode.Forbidden));
});
}
/// <summary>Ein Abstand von null heisst: diese Aufgabe läuft nicht von selbst.</summary>
[Test]
public async Task Abgeschaltet_bedeutet_kein_Faelligkeitstermin()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupDays: 0));
var stand = await client.GetFromJsonAsync<List<Wartungsstand>>("/api/v1/system/maintenance");
Assert.That(stand!.First(s => s.Aufgabe == "Sicherung").Faellig, Is.Null);
}
/// <summary>Die Einstellungen des Plans kommen zurück, wie sie gesetzt wurden.</summary>
[Test]
public async Task Die_Vorgaben_des_Plans_stehen_und_lassen_sich_aendern()
{
using var server = new FlaroServer();
var client = await AlsAdmin(server);
var vorher = await client.GetFromJsonAsync<MaintenanceSettingsResponse>("/api/v1/settings/maintenance");
await client.PutAsJsonAsync("/api/v1/settings/maintenance",
new MaintenanceSettingsRequest(null, null, null, BackupDays: 1, BackupKeep: 10));
var nachher = await client.GetFromJsonAsync<MaintenanceSettingsResponse>("/api/v1/settings/maintenance");
Assert.Multiple(() =>
{
Assert.That(vorher!.BackupDays, Is.EqualTo(3));
Assert.That(vorher.OptimizeDays, Is.EqualTo(7));
Assert.That(nachher!.BackupDays, Is.EqualTo(1));
Assert.That(nachher.BackupKeep, Is.EqualTo(10));
Assert.That(nachher.OptimizeDays, Is.EqualTo(7), "was nicht geschickt wurde, bleibt");
Assert.That(nachher.CapGb, Is.EqualTo(20).Within(0.01), "und der Kratzplatz auch");
});
}
}