diff --git a/server/src/Flaro.Api/Einwand.cs b/server/src/Flaro.Api/Einwand.cs
new file mode 100644
index 0000000..11fad3d
--- /dev/null
+++ b/server/src/Flaro.Api/Einwand.cs
@@ -0,0 +1,48 @@
+namespace Flaro.Api;
+
+/// Ein abgelehnter Versuch — mit einem Satz, den die Clients auch lesen.
+///
+///
+///
+/// Es gab drei Formen, und zwei davon kamen nie an. Gemessen: die
+/// Oberfläche las bei jedem abgelehnten Versuch „Die Anfrage war so nicht zu
+/// gebrauchen" — ihren eigenen Ersatzsatz für eine 400 ohne Begründung. Dabei
+/// hatte der Server sehr wohl einen deutschen Satz geschrieben, nur an eine
+/// Stelle, an der niemand nachsieht.
+///
+///
+/// Results.ValidationProblem legt ihn unter errors ab,
+/// Results.BadRequest(new { fehler = … }) unter fehler. Beide
+/// Clients lesen detail — der Browser in api.ts, die Apps in
+/// FlaroClient.swift —, und zwar aus gutem Grund: in title und in
+/// errors stehen sonst die englischen Prüftexte des Rahmenwerks, und
+/// „Not Found" ist keine Auskunft. Nur stand in detail an 28 Stellen
+/// eben gar nichts.
+///
+///
+/// Also eine Form. Results.Problem(detail, …) füllt genau das
+/// Feld, das gelesen wird. Dass es hier durch drei benannte Türen geht statt
+/// durch einen Aufruf mit Zahl, hat einen Grund: so lässt sich nachsehen, wo
+/// ein Server etwas ablehnt, und man sieht am Wort, ob es eine Bitte um eine
+/// bessere Eingabe war, etwas Fehlendes oder ein Zusammenstoss.
+///
+///
+/// Nicht für Ausnahmen. Was unerwartet schiefgeht, übersetzt
+/// . Hier geht es um Absichten, die der Server
+/// zurückweist — nicht um Unfälle.
+///
+///
+public static class Einwand
+{
+ /// 400 — so war die Eingabe nicht zu gebrauchen.
+ public static IResult Bitte(string satz) =>
+ Results.Problem(satz, statusCode: StatusCodes.Status400BadRequest);
+
+ /// 404 — das, worauf es sich bezieht, gibt es nicht.
+ public static IResult Fehlt(string satz) =>
+ Results.Problem(satz, statusCode: StatusCodes.Status404NotFound);
+
+ /// 409 — es gibt schon etwas, das im Weg steht.
+ public static IResult Belegt(string satz) =>
+ Results.Problem(satz, statusCode: StatusCodes.Status409Conflict);
+}
diff --git a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs
index c0b212b..cbe1be5 100644
--- a/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/ArtworkEndpoints.cs
@@ -65,7 +65,7 @@ public static class ArtworkEndpoints
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
{
if (anfrage.Kind is not ("poster" or "backdrop" or "logo"))
- return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
+ return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
// Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird —
// sonst zeigt der Eintrag auf etwas, das es hier nicht gibt.
@@ -88,15 +88,15 @@ public static class ArtworkEndpoints
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
{
if (kind is not ("poster" or "backdrop" or "logo"))
- return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
- if (!anfrage.HasFormContentType) return Results.BadRequest(new { fehler = "Kein Formular." });
+ return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
+ if (!anfrage.HasFormContentType) return Einwand.Bitte("Kein Formular.");
var form = await anfrage.ReadFormAsync(ct);
var datei = form.Files.FirstOrDefault();
if (datei is null || datei.Length == 0)
- return Results.BadRequest(new { fehler = "Keine Datei dabei." });
+ return Einwand.Bitte("Keine Datei dabei.");
if (datei.Length > HoechstensBytes)
- return Results.BadRequest(new { fehler = "Höchstens 10 MB." });
+ return Einwand.Bitte("Höchstens 10 MB.");
await using var strom = datei.OpenReadStream();
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
diff --git a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs
index 640ba12..3f2ee69 100644
--- a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs
@@ -160,7 +160,7 @@ public static class AuthEndpoints
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
if (offen is null)
- return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound);
+ return Einwand.Fehlt("Dieser Code gilt nicht mehr.");
offen.ApprovedByUserId = benutzerId;
await db.SaveChangesAsync();
diff --git a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs
index e326795..ecb98ac 100644
--- a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs
@@ -183,7 +183,7 @@ public static class FreigabeEndpoints
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (anfrage.Ids is null or { Count: 0 })
- return Results.BadRequest(new { fehler = "Ohne Kennungen gibt es nichts zurückzuziehen." });
+ return Einwand.Bitte("Ohne Kennungen gibt es nichts zurückzuziehen.");
var ids = anfrage.Ids;
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs
index 56ec182..a7155cb 100644
--- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs
@@ -23,13 +23,10 @@ public static class ManagementEndpoints
{
var name = anfrage.UserName.Trim();
if (name.Length == 0 || anfrage.Password.Length < 8)
- return Results.ValidationProblem(new Dictionary
- {
- ["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
- });
+ return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
if (await db.Users.AnyAsync(u => u.UserName == name))
- return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict);
+ return Einwand.Belegt($"„{name}“ gibt es schon.");
var neu = new User
{
@@ -175,7 +172,7 @@ public static class ManagementEndpoints
ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (anfrage.Ids is null or { Count: 0 })
- return Results.BadRequest(new { fehler = "Ohne Reihenfolge gibt es nichts zu ordnen." });
+ return Einwand.Bitte("Ohne Reihenfolge gibt es nichts zu ordnen.");
// Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden.
for (var i = 0; i < anfrage.Ids.Count; i++)
@@ -362,20 +359,14 @@ public static class ManagementEndpoints
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
{
if (!Enum.TryParse(anfrage.Kind, ignoreCase: true, out var art))
- return Results.ValidationProblem(new Dictionary
- {
- ["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
- });
+ return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows.");
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
// die Ursache stünde allenfalls im Protokoll.
if (!Directory.Exists(anfrage.Path))
- return Results.ValidationProblem(new Dictionary
- {
- ["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " +
- "Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."],
- });
+ return Einwand.Bitte($"„{anfrage.Path}“ gibt es im Container nicht. " +
+ "Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt.");
var neu = new Library
{
@@ -399,10 +390,7 @@ public static class ManagementEndpoints
{
var name = anfrage.Name.Trim();
if (name.Length == 0)
- return Results.ValidationProblem(new Dictionary
- {
- ["name"] = ["Ein Name darf nicht leer sein."],
- });
+ return Einwand.Bitte("Ein Name darf nicht leer sein.");
var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound();
diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
index 324becf..0c008e9 100644
--- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
@@ -40,16 +40,10 @@ public static class MatchEndpoints
var titel = anfrage.Title?.Trim();
if (string.IsNullOrEmpty(titel))
{
- return Results.ValidationProblem(new Dictionary
- {
- ["title"] = ["Ein Titel darf nicht leer sein."],
- });
+ return Einwand.Bitte("Ein Titel darf nicht leer sein.");
}
if (titel.Length > 512)
- return Results.ValidationProblem(new Dictionary
- {
- ["title"] = ["Höchstens 512 Zeichen."],
- });
+ return Einwand.Bitte("Höchstens 512 Zeichen.");
// **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.**
// Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg.
@@ -84,8 +78,7 @@ public static class MatchEndpoints
statusCode: StatusCodes.Status400BadRequest);
}
if (sortiert.Length > 512)
- return Results.Problem("Höchstens 512 Zeichen.",
- statusCode: StatusCodes.Status400BadRequest);
+ return Einwand.Bitte("Höchstens 512 Zeichen.");
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
@@ -283,8 +276,7 @@ public static class MatchEndpoints
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
if (gefunden is null)
- return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
- statusCode: StatusCodes.Status404NotFound);
+ return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht.");
// **Hat die Kennung schon jemand, ist es derselbe Film.**
//
@@ -343,8 +335,7 @@ public static class MatchEndpoints
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
if (gefunden is null)
- return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
- statusCode: StatusCodes.Status404NotFound);
+ return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht.");
// Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie,
// und zwei Ordner derselben Serie gehören zusammengelegt.
diff --git a/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs b/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs
index aced4a0..8b5c7dc 100644
--- a/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs
@@ -51,20 +51,17 @@ public static class MeldungEndpoints
var kommentar = anfrage.Comment?.Trim() ?? "";
if (kommentar.Length == 0)
- return Results.BadRequest(new { fehler = "Ohne Beschreibung lässt sich nichts nachsehen." });
+ return Einwand.Bitte("Ohne Beschreibung lässt sich nichts nachsehen.");
if (kommentar.Length > HoechstensZeichen)
- return Results.BadRequest(new { fehler = $"Höchstens {HoechstensZeichen} Zeichen." });
+ return Einwand.Bitte($"Höchstens {HoechstensZeichen} Zeichen.");
if (AlsArt(anfrage.Kind) is not { } art)
{
- return Results.BadRequest(new
- {
- fehler = "Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.",
- });
+ return Einwand.Bitte("Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.");
}
if (!AlsStelle(anfrage.Position, out var stelle))
- return Results.BadRequest(new { fehler = "Die Stelle muss wie 0:34:12 aussehen." });
+ return Einwand.Bitte("Die Stelle muss wie 0:34:12 aussehen.");
// **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen
// Titel nicht sehen darf, findet ihn hier nicht — ohne ein
@@ -74,15 +71,15 @@ public static class MeldungEndpoints
if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id;
else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id;
else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id;
- else return Results.BadRequest(new { fehler = "Diesen Titel gibt es nicht." });
+ else return Einwand.Bitte("Diesen Titel gibt es nicht.");
int? staffel = null;
if (anfrage.SeasonNumber is { } nummer)
{
if (serie is null)
- return Results.BadRequest(new { fehler = "Eine Staffel gibt es nur zu einer Serie." });
+ return Einwand.Bitte("Eine Staffel gibt es nur zu einer Serie.");
if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct))
- return Results.BadRequest(new { fehler = "Diese Staffel gibt es in der Serie nicht." });
+ return Einwand.Bitte("Diese Staffel gibt es in der Serie nicht.");
staffel = nummer;
}
@@ -157,7 +154,7 @@ public static class MeldungEndpoints
{
var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct);
if (meldung is null)
- return Results.NotFound(new { fehler = "Diese Meldung gibt es nicht mehr." });
+ return Einwand.Fehlt("Diese Meldung gibt es nicht mehr.");
meldung.ErledigtAm = wann;
meldung.ErledigtVon = wann is null ? null : Kennung(wer);
diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
index a5b1536..8e1f02b 100644
--- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
@@ -235,8 +235,7 @@ public static class PlaybackEndpoints
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
- return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.",
- statusCode: StatusCodes.Status400BadRequest);
+ return Einwand.Bitte("Eine Bewertung liegt zwischen 1 und 10.");
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
diff --git a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs
index f526b41..6a5336d 100644
--- a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs
@@ -55,14 +55,14 @@ public static class ProfilEndpoints
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
if (!anfrage.HasFormContentType)
- return Results.BadRequest(new { fehler = "Kein Formular." });
+ return Einwand.Bitte("Kein Formular.");
var form = await anfrage.ReadFormAsync(ct);
var datei = form.Files.FirstOrDefault();
if (datei is null || datei.Length == 0)
- return Results.BadRequest(new { fehler = "Keine Datei dabei." });
+ return Einwand.Bitte("Keine Datei dabei.");
if (datei.Length > HoechstensBytes)
- return Results.BadRequest(new { fehler = "Höchstens 5 MB." });
+ return Einwand.Bitte("Höchstens 5 MB.");
await using var strom = datei.OpenReadStream();
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs
index 8885fbc..2951ad4 100644
--- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs
@@ -81,9 +81,7 @@ public static class SettingsEndpoints
return gestartet > 0
? Results.Accepted(value: new { started = gestartet, language = serversprache })
- : Results.Problem(
- "In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.",
- statusCode: StatusCodes.Status409Conflict);
+ : Einwand.Belegt("In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.");
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/metadata", async (
@@ -96,10 +94,7 @@ public static class SettingsEndpoints
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
// Englisch an, und niemand wüsste, warum.
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
- return Results.ValidationProblem(new Dictionary
- {
- ["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."],
- });
+ return Einwand.Bitte($"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“.");
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
}
diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
index a81f80f..1848fdf 100644
--- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
@@ -88,10 +88,7 @@ public static class SystemEndpoints
app.MapGet("/api/v1/system/folders", (string? path) =>
Ordnerbaum.Lesen(path) is { } baum
? Results.Ok(baum)
- : Results.Problem(
- title: "Ordner nicht gefunden",
- detail: $"Den Ordner {path} gibt es nicht, oder es ist keiner.",
- statusCode: StatusCodes.Status404NotFound))
+ : Einwand.Fehlt($"Den Ordner {path} gibt es nicht, oder es ist keiner."))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Dubletten von Hand zusammenlegen.** Nach einem Scan passiert das
@@ -122,7 +119,7 @@ public static class SystemEndpoints
if (!DateOnly.TryParseExact(tag, "yyyy-MM-dd", null,
System.Globalization.DateTimeStyles.None, out var tagesdatum))
{
- return Results.BadRequest(new { fehler = "Erwartet wird ein Datum als JJJJ-MM-TT." });
+ return Einwand.Bitte("Erwartet wird ein Datum als JJJJ-MM-TT.");
}
var datei = tagebuch.DateiFuer(tagesdatum);
@@ -170,7 +167,7 @@ public static class SystemEndpoints
{
var sprache = anfrage.Language?.Trim();
if (string.IsNullOrEmpty(sprache))
- return Results.BadRequest(new { fehler = "Ohne Sprache gibt es nichts zu holen." });
+ return Einwand.Bitte("Ohne Sprache gibt es nichts zu holen.");
tagebuch.Schreiben($"Übersetzungen für {sprache} werden geholt.");
var bericht = await uebersetzer.HolenAsync(sprache, null, ct);
@@ -183,10 +180,7 @@ public static class SystemEndpoints
{
var name = anfrage.Name.Trim();
if (name.Length == 0)
- return Results.ValidationProblem(new Dictionary
- {
- ["name"] = ["Ein Name darf nicht leer sein."],
- });
+ return Einwand.Bitte("Ein Name darf nicht leer sein.");
var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel);
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name });
@@ -199,10 +193,7 @@ public static class SystemEndpoints
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
{
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
- return Results.ValidationProblem(new Dictionary
- {
- ["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
- });
+ return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
await EinrichtungsSchloss.WaitAsync();
try
@@ -210,8 +201,7 @@ public static class SystemEndpoints
// Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die
// Antwort schon veraltet, wenn sie ankommt.
if (await db.Users.AnyAsync())
- return Results.Problem(
- "Dieser Server ist bereits eingerichtet.", statusCode: StatusCodes.Status409Conflict);
+ return Einwand.Belegt("Dieser Server ist bereits eingerichtet.");
var administrator = new User
{
diff --git a/server/tests/Flaro.Api.Tests/FehlersatzTests.cs b/server/tests/Flaro.Api.Tests/FehlersatzTests.cs
new file mode 100644
index 0000000..3801976
--- /dev/null
+++ b/server/tests/Flaro.Api.Tests/FehlersatzTests.cs
@@ -0,0 +1,144 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Flaro.Api.Endpoints;
+
+namespace Flaro.Api.Tests;
+
+/// Ein abgelehnter Versuch sagt, warum — und zwar dort, wo gelesen wird.
+///
+///
+///
+/// Was hier schiefging, war unsichtbar. Der Server schrieb seine
+/// deutschen Sätze in errors (bei ValidationProblem) oder in ein
+/// selbstgebautes Feld fehler. Beide Clients lesen detail — und
+/// nur das —, weil in title und errors sonst die englischen
+/// Prüftexte des Rahmenwerks stehen. Also las man im Browser bei jedem
+/// abgelehnten Versuch denselben Ersatzsatz: „Die Anfrage war so nicht zu
+/// gebrauchen."
+///
+///
+/// Auffallen konnte das nicht: die vorhandenen Tests prüften den Rumpf als
+/// Zeichenkette (Does.Contain("eingeh")), und darin stand der Satz ja.
+/// Nur eben an der falschen Stelle. Deshalb prüft dieser Test nicht, dass
+/// ein Satz im Rumpf steht, sondern in welchem Feld.
+///
+///
+public class FehlersatzTests
+{
+ private static async Task<(HttpStatusCode Code, string? Detail)> Lesen(HttpResponseMessage antwort)
+ {
+ var roh = await antwort.Content.ReadAsStringAsync();
+ using var papier = JsonDocument.Parse(roh);
+ return (antwort.StatusCode,
+ papier.RootElement.TryGetProperty("detail", out var d) ? d.GetString() : null);
+ }
+
+ [Test]
+ public async Task Ein_zu_kurzes_Passwort_sagt_warum()
+ {
+ using var server = new FlaroServer();
+ var admin = await server.SetUpAndSignIn();
+ var alsAdmin = server.AlsAngemeldeter(admin.Token);
+
+ var versuch = await alsAdmin.PostAsJsonAsync("/api/v1/users",
+ new CreateUserRequest("kurz", "1234", false));
+ var (code, detail) = await Lesen(versuch);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(code, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(detail, Does.Contain("8 Zeichen"),
+ "Der Satz muss in `detail` stehen — nur das lesen die Clients.");
+ });
+ }
+
+ [Test]
+ public async Task Ein_vergebener_Name_sagt_welcher()
+ {
+ using var server = new FlaroServer();
+ var admin = await server.SetUpAndSignIn();
+ var alsAdmin = server.AlsAngemeldeter(admin.Token);
+
+ await alsAdmin.PostAsJsonAsync("/api/v1/users",
+ new CreateUserRequest("doppelt", "einlangespasswort", false));
+ var zweites = await alsAdmin.PostAsJsonAsync("/api/v1/users",
+ new CreateUserRequest("doppelt", "einlangespasswort", false));
+ var (code, detail) = await Lesen(zweites);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(code, Is.EqualTo(HttpStatusCode.Conflict));
+ Assert.That(detail, Does.Contain("doppelt"));
+ });
+ }
+
+ /// Die zweite der beiden alten Formen — der selbstgebaute Rumpf.
+ [Test]
+ public async Task Eine_Meldung_ohne_Text_sagt_warum()
+ {
+ using var server = new FlaroServer();
+ var admin = await server.SetUpAndSignIn();
+ var alsAdmin = server.AlsAngemeldeter(admin.Token);
+
+ // Die Kennung gibt es nicht — das ist hier gerade recht: auch dieser
+ // Satz ging früher den unsichtbaren Weg.
+ var versuch = await alsAdmin.PostAsJsonAsync(
+ $"/api/v1/items/{Guid.CreateVersion7()}/reports",
+ new MeldungRequest("bild", "Etwas stimmt nicht."));
+ var (code, detail) = await Lesen(versuch);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(code, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(detail, Is.EqualTo("Diesen Titel gibt es nicht."));
+ });
+ }
+
+ /// Damit die beiden Formen nicht zurückkommen.
+ ///
+ ///
+ /// Ein Wächter am Quelltext, kein Test am Verhalten. Die nächste
+ /// Ablehnung wird irgendwann neu geschrieben, und dann liegt
+ /// Results.BadRequest(new { fehler = … }) näher als
+ /// — es sieht ja richtig aus. Ein Test, der
+ /// jede Ablehnung einzeln abklappert, wäre dagegen genau so vollständig wie
+ /// die Liste, die jemand pflegt. Dieselbe Art Wächter steht in
+ /// FreigabeTests über den Inhaltsrouten.
+ ///
+ [Test]
+ public void Keine_Ablehnung_geht_mehr_an_detail_vorbei()
+ {
+ var wurzel = Quellordner();
+ var endpunkte = Directory.GetFiles(
+ Path.Combine(wurzel, "src", "Flaro.Api"), "*.cs", SearchOption.AllDirectories);
+
+ var ertappt = new List();
+ foreach (var datei in endpunkte)
+ {
+ if (Path.GetFileName(datei) is "Einwand.cs") continue;
+ var inhalt = File.ReadAllText(datei);
+ if (inhalt.Contains("ValidationProblem"))
+ ertappt.Add($"{Path.GetFileName(datei)}: ValidationProblem");
+ // Auf die Form, nicht auf das Wort: `fehler` heisst auch manche
+ // örtliche Variable, etwa im Ausnahmebehandler in `Program.cs`.
+ if (System.Text.RegularExpressions.Regex.IsMatch(inhalt, @"new\s*\{\s*fehler"))
+ ertappt.Add($"{Path.GetFileName(datei)}: new {{ fehler = … }}");
+ }
+
+ Assert.That(ertappt, Is.Empty,
+ "Diese Form legt den Satz in ein Feld, das kein Client liest. "
+ + "Einwand.Bitte/Fehlt/Belegt schreibt ihn nach `detail`.");
+ }
+
+ /// Findet `server/` von den Testbinärdateien aus.
+ private static string Quellordner()
+ {
+ var ordner = new DirectoryInfo(AppContext.BaseDirectory);
+ while (ordner is not null && !Directory.Exists(Path.Combine(ordner.FullName, "src", "Flaro.Api")))
+ ordner = ordner.Parent;
+
+ Assert.That(ordner, Is.Not.Null, "Der Quellordner war von den Tests aus nicht zu finden.");
+ return ordner!.FullName;
+ }
+}
diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts
index 4e404e1..a7b3878 100644
--- a/web/app/src/dialog.ts
+++ b/web/app/src/dialog.ts
@@ -204,6 +204,12 @@ export function dialog(optionen: {
// obwohl man sichtbar etwas geschrieben hat.
if (e instanceof HTMLTextAreaElement) { gesammelt.set(name, e.value.trim()); return; }
if (!(e instanceof HTMLInputElement)) { gesammelt.set(name, e.wert); return; }
+ // **Ein Passwort wird nicht beschnitten.** Überall sonst sind Ränder
+ // ein Versehen; in einem Passwort sind sie Inhalt. Getrimmt legte der
+ // Verwaltungsdialog ein Konto an, in das sich niemand anmelden konnte —
+ // `login.ts` sendet es ungeschoren, und die beiden Zeichenketten waren
+ // dann verschieden.
+ if (e.type === 'password') { gesammelt.set(name, e.value); return; }
gesammelt.set(name, e.type === 'checkbox' ? String(e.checked) : e.value.trim());
});
diff --git a/web/app/src/views/einstellungen/benutzer.ts b/web/app/src/views/einstellungen/benutzer.ts
index 9bb43cc..e62feea 100644
--- a/web/app/src/views/einstellungen/benutzer.ts
+++ b/web/app/src/views/einstellungen/benutzer.ts
@@ -24,7 +24,16 @@ export function benutzerbereich(): HTMLElement {
],
bestaetigen: 'Anlegen',
tun: async wert => {
- await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true');
+ // **Hier, nicht erst am Server.** Der Dialog bleibt bei einem Wurf offen
+ // und zeigt den Satz; einmal hin und zurück über die Leitung wäre dafür
+ // Verschwendung. Der Server prüft es trotzdem.
+ const name = wert('name').trim();
+ if (name.length === 0) throw new Error('Ohne Namen geht es nicht.');
+ if (wert('passwort').length < 8) {
+ throw new Error('Das Passwort braucht mindestens acht Zeichen.');
+ }
+
+ await benutzerAnlegen(name, wert('passwort'), wert('admin') === 'true');
await laden();
},
});
diff --git a/web/app/src/views/setup.ts b/web/app/src/views/setup.ts
index 6be99b7..5d10591 100644
--- a/web/app/src/views/setup.ts
+++ b/web/app/src/views/setup.ts
@@ -25,7 +25,7 @@ export function einrichtung(fertig: () => void): HTMLElement {
karte.append(titel, text, name, passwort, los);
async function absenden(): Promise {
- karte.querySelector('.error')?.remove();
+ karte.querySelector('.fehler')?.remove();
los.disabled = true;
try {
await einrichten(name.value.trim(), passwort.value);