Files
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00

335 lines
14 KiB
C#

using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Einladen statt ein Konto für jemanden erfinden.</summary>
///
/// <remarks>
/// <b>Der Test, der hier wirklich zählt, ist der mit den zwei gleichzeitigen
/// Einlösungen.</b> Ein einmaliger Link, zweimal im selben Augenblick
/// geöffnet, ist der einzige Fall, den man von Hand nie findet — und der
/// einzige, bei dem ein falscher Entwurf zwei Konten statt einem anlegt.
/// </remarks>
public class EinladungTests
{
private static async Task<EinladungErstellt> Einladen(
HttpClient alsAdmin, int tage = 7, int? wieOft = 1)
{
var antwort = await alsAdmin.PostAsJsonAsync("/api/v1/invites",
new EinladungRequest(tage, wieOft));
antwort.EnsureSuccessStatusCode();
return (await antwort.Content.ReadFromJsonAsync<EinladungErstellt>())!;
}
private static Task<HttpResponseMessage> Einloesen(
HttpClient ohne, string token, string name, string passwort = "einlangespasswort") =>
ohne.PostAsJsonAsync($"/api/v1/invites/{token}/redeem", new EinloesenRequest(name, passwort));
[Test]
public async Task Wer_den_Link_hat_legt_sich_ein_Konto_an()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
var fremder = server.OhneGedaechtnis();
var schau = await fremder.GetFromJsonAsync<Einladungsschau>(
$"/api/v1/invites/{einladung.Token}");
var angelegt = await Einloesen(fremder, einladung.Token, "nachbarin");
var konten = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
Assert.Multiple(() =>
{
Assert.That(schau!.InvitedBy, Is.EqualTo("guido"));
Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(konten!.Select(k => k.UserName), Does.Contain("nachbarin"));
});
}
[Test]
public async Task Wer_eingeladen_wird_ist_kein_Administrator()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin");
var anmeldung = await server.SignIn("nachbarin", "einlangespasswort", "Prüfgerät", "p1");
var alsGast = server.AlsAngemeldeter(anmeldung.Token);
var verwaltung = await alsGast.GetAsync("/api/v1/invites");
Assert.Multiple(() =>
{
Assert.That(anmeldung.User.IsAdministrator, Is.False);
Assert.That(verwaltung.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
});
}
[Test]
public async Task Ein_eingeloester_Link_gilt_nicht_zweimal()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
await Einloesen(server.OhneGedaechtnis(), einladung.Token, "erste");
var zweite = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zweite");
Assert.That(zweite.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(await zweite.Content.ReadAsStringAsync(), Does.Contain("schon benutzt"));
}
/// <summary>Zwei im selben Augenblick — und trotzdem nur ein Konto.</summary>
[Test]
public async Task Zwei_gleichzeitige_Einloesungen_ergeben_ein_Konto()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
var beide = await Task.WhenAll(
Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine"),
Einloesen(server.OhneGedaechtnis(), einladung.Token, "andere"));
var konten = await alsAdmin.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
var dazu = konten!.Count(k => k.UserName is "eine" or "andere");
Assert.Multiple(() =>
{
Assert.That(beide.Count(a => a.IsSuccessStatusCode), Is.EqualTo(1),
"Genau eine Einlösung darf durchkommen.");
Assert.That(dazu, Is.EqualTo(1), "Und genau ein Konto darf entstanden sein.");
});
}
[Test]
public async Task Mehrfachlinks_gelten_so_oft_wie_angegeben()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin, wieOft: 2);
var eine = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "eine");
var zwei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zwei");
var drei = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "drei");
var liste = await alsAdmin.GetFromJsonAsync<List<Einladungseintrag>>("/api/v1/invites");
Assert.Multiple(() =>
{
Assert.That(eine.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(zwei.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(drei.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(liste![0].UsedCount, Is.EqualTo(2));
Assert.That(liste[0].Valid, Is.False);
});
}
[Test]
public async Task Ein_zurueckgezogener_Link_wird_abgewiesen()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
(await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}")).EnsureSuccessStatusCode();
var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet");
var schau = await server.OhneGedaechtnis().GetAsync($"/api/v1/invites/{einladung.Token}");
Assert.Multiple(() =>
{
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
});
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("zurückgezogen"));
}
[Test]
public async Task Ein_abgelaufener_Link_wird_abgewiesen()
{
var uhr = new Stelluhr(DateTimeOffset.UtcNow);
using var server = new FlaroServer(dienste => dienste.AddSingleton<TimeProvider>(uhr));
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin, tage: 1);
uhr.Weiter(TimeSpan.FromDays(2));
var versuch = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "zuspaet");
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("abgelaufen"));
}
[Test]
public async Task Einen_Link_den_es_nicht_gibt_kennt_niemand()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var versuch = await Einloesen(server.OhneGedaechtnis(), "ausgedacht", "wer");
var schau = await server.OhneGedaechtnis().GetAsync("/api/v1/invites/ausgedacht");
Assert.Multiple(() =>
{
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(schau.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
});
}
[Test]
public async Task Einladen_darf_nur_die_Verwaltung()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
var erstellen = await gast.PostAsJsonAsync("/api/v1/invites", new EinladungRequest());
var lesen = await gast.GetAsync("/api/v1/invites");
Assert.Multiple(() =>
{
Assert.That(erstellen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That(lesen.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
});
}
/// <summary>Der Link steht genau einmal da — danach nicht einmal mehr dem Admin.</summary>
[Test]
public async Task Der_Token_steht_nur_im_Erstellen_nicht_in_der_Liste()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
var roh = await (await alsAdmin.GetAsync("/api/v1/invites")).Content.ReadAsStringAsync();
Assert.That(einladung.Token, Is.Not.Empty);
Assert.That(roh, Does.Not.Contain(einladung.Token),
"Gespeichert ist nur der Fingerfingerprint — aus der Liste darf kein Link zu bauen sein.");
}
/// <summary>Ein Tippfehler im Namen darf den Link nicht verbrennen.</summary>
[Test]
public async Task Ein_vergebener_Name_verbraucht_die_Einladung_nicht()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
var belegt = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "guido");
var danach = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "nachbarin");
Assert.Multiple(() =>
{
Assert.That(belegt.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.NoContent),
"Der Link muss nach dem Fehlgriff noch gelten.");
});
}
[Test]
public async Task Ohne_langes_Passwort_geht_es_nicht()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
var kurz = await Einloesen(server.OhneGedaechtnis(), einladung.Token, "wer", "1234");
var leer = await Einloesen(server.OhneGedaechtnis(), einladung.Token, " ");
Assert.Multiple(() =>
{
Assert.That(kurz.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
});
}
/// <summary>Eine zurückgezogene Einladung lässt sich endgültig entfernen.</summary>
///
/// <remarks>
/// <b>Warum das überhaupt nötig ist.</b> Zurückziehen behält die Spur
/// absichtlich — man will wissen, dass es den Link gab, wenn jemand
/// Unerwartetes im Haushalt auftaucht. Nur verlässt die Zeile die Liste
/// dann nie, und eine Liste, aus der nichts verschwindet, wird
/// irgendwann unlesbar. Weggeworfen wird deshalb nur, was ohnehin keine
/// Tür mehr öffnet.
/// </remarks>
[Test]
public async Task Eine_zurueckgezogene_Einladung_laesst_sich_entfernen()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
(await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}")).EnsureSuccessStatusCode();
var weg = await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}?purge=true");
var liste = await alsAdmin.GetFromJsonAsync<List<Einladungseintrag>>("/api/v1/invites");
Assert.Multiple(() =>
{
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(liste, Is.Empty, "die Zeile muss die Liste verlassen");
});
}
/// <summary>Ein gültiger Link lässt sich nicht wegwischen.</summary>
///
/// <remarks>
/// Sonst wäre „Entfernen" ein stilles Zurückziehen mit Gedächtnisverlust:
/// der Link gälte weiter, stünde aber nirgends mehr. Erst zurückziehen,
/// dann entfernen — ein Handgriff mehr, mit Absicht.
/// </remarks>
[Test]
public async Task Ein_gueltiger_Link_laesst_sich_nicht_entfernen()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin);
var weg = await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}?purge=true");
var liste = await alsAdmin.GetFromJsonAsync<List<Einladungseintrag>>("/api/v1/invites");
Assert.Multiple(() =>
{
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(liste, Has.Count.EqualTo(1));
Assert.That(liste![0].Valid, Is.True, "und er gilt danach immer noch");
});
}
/// <summary>Ein aufgebrauchter Link darf weg, auch ohne Zurückziehen.</summary>
[Test]
public async Task Ein_aufgebrauchter_Link_laesst_sich_entfernen()
{
using var server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var alsAdmin = server.AlsAngemeldeter(admin.Token);
var einladung = await Einladen(alsAdmin, wieOft: 1);
(await Einloesen(server.OhneGedaechtnis(), einladung.Token, "gast")).EnsureSuccessStatusCode();
var weg = await alsAdmin.DeleteAsync($"/api/v1/invites/{einladung.Id}?purge=true");
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
}
}