Files
Flaro/server/tests/Flaro.Api.Tests/EinladungTests.cs
T
ScarriffleandClaude Opus 5 2809821c9b Vier Fehler, und bei zweien log der Kommentar daneben
**Der Einstellungsknopf lag hinter der Abspielleiste.** Gemessen: `.pl`
steht auf Stufe 150, `#chrome` auf gar keiner, und die kleine Leiste spannte
über die ganze Breite — genau über die unteren 72 Bildpunkte der
Seitenleiste, wo „Einstellungen" klebt. Weil der Spieler bei jedem Wechsel
der Adresse minimiert, stand sie zuverlässig im Weg, sobald man dorthin
wollte.

Die Leiste beginnt jetzt hinter der Seitenleiste. Nicht über die Stufen
gelöst, und das ist der Punkt: `#chrome` höher zu setzen hiesse, die
Seitenleiste läge auch über dem grossen Spieler — es ist dieselbe Hülle.
Dass diese Leiste Platz wegnimmt statt sich darüberzulegen, stand für
Inhalt, Hinweise und Auswahlleiste ohnehin schon da; die Seitenleiste war
die vergessene vierte. Gemessen, 1400 Bildpunkte breit: Leiste ab 240,
Knopf bei 0, keine Überschneidung, und ein Treffertest in der Knopfmitte
trifft den Knopf.

**Ein Klick auf eine Kategorie kam nie an.** Dort sprang der Code in die
Mediathek und suchte danach `.filterzeile select`, um den Wert zu setzen.
Die Filterzeile hat kein `<select>` — `auswahl()` baut einen Knopf, mit
Absicht und begründet. Der Zweig lief also nie, und man landete in der
ungefilterten Mediathek. Der Kommentar daneben behauptete das Gegenteil.

Das Genre steht jetzt in der Adresse: `#/m/{id}/mediathek/Horror`. Das
löst zugleich das zweite Problem — die Mediathek wird unter ihrem Weg
gemerkt, und stünde das Genre nur im Kopf, zeigte ein zweiter Besuch die
Liste des ersten. Geprüft: Klick setzt den Weg und die Pille, Neuladen
behält beides, Zurück führt auf die Kategorien, Vorwärts wieder hinein.

**Zurückgezogene Einladungen blieben für immer stehen.** Der Knopfblock
steckte ganz in `if (!e.revokedAt)`, eine erledigte Zeile hatte also gar
keinen Knopf. Die Serverbegründung — eine Zeile wegzuwerfen hiesse,
hinterher nicht mehr zu wissen, dass es den Link je gab — bleibt richtig,
sie gilt nur nicht ewig: eine Liste, aus der nichts verschwindet, wird
unlesbar, und dann sieht man die eine Zeile nicht mehr, um die es ging.

Deshalb kein umgedeutetes DELETE, sondern `?purge=true`, und das greift nur
bei einem Link, der ohnehin keine Tür mehr öffnet. Ein gültiger wird mit
einem deutschen Satz abgewiesen.

**Poster in der falschen Sprache.** „365 Tage" stand mit polnischem Plakat
da. Kein fehlender Anbieter: für den Schriftzug gab es die sprachgewichtete
Wahl längst, fürs Plakat wurde das blanke `poster_path` der Hauptantwort
genommen — bei TMDB das Bild der Originalsprache, wenn in der gewünschten
keines eingetragen ist. Die sortierte Liste lag im selben Abruf schon vor
und wurde ausschliesslich für den Schriftzug benutzt.

Plakat und Hintergrund nehmen jetzt dieselbe Rangfolge: eingestellte
Sprache, dann sprachlos, dann Englisch. Sprachlos vor Englisch ist keine
Verlegenheit — ein Bild ohne Sprachkennung trägt keinen Text, und ein
Plakat ohne Titel ist für einen deutschen Bestand besser als dasselbe mit
englischem. Im Bilderdialog stehen die Kandidaten jetzt ebenso sortiert.
Bestehende Titel ändern sich beim nächsten Auffrischen; von Hand gesetzte
Plakate bleiben unangetastet.

676 Tests grün, davon zehn neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 17:46:46 +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 Fingerabdruck — 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));
}
}