Files
Flaro/server/tests/Flaro.Api.Tests/DeliveryTests.cs
T
ScarriffleandClaude Opus 5 67cf0fd1d9 HEAD auf den Download, und /api verschluckt nichts mehr
Zwei Fehler, derselbe Grund — und beide zeigte erst der Browser.

**Der Spieler bekam die Webseite statt des Films.** „MEDIA_ELEMENT_ERROR:
Format error", 531 Byte, `Content-Type: text/html`. Das ist genau die Grösse
von `index.html`.

`MapGet` nimmt HEAD nicht an. Die Oberfläche fragt aber mit HEAD nach, ob das
Umpacken fertig ist — einen halbfertigen Film herunterzuladen, nur um das zu
erfahren, wäre absurd. Die Anfrage fiel durch den Methodenabgleich hindurch
bis zum SPA-Rückfall, und der nimmt jeden Pfad ohne Dateiendung. Antwort:
200, mit der Startseite im Rumpf. Der Client hielt das Umpacken für fertig
und reichte HTML an `<video>` weiter.

Jetzt `MapMethods` mit GET und HEAD. Kestrel lässt den Rumpf bei HEAD selbst
weg.

**Und ein vertippter Aufruf sah aus wie ein Erfolg.** `GET /api/v1/gibtsnicht`
gab 200 und die Webseite zurück — derselbe Rückfall. Ein Fehler, der erst
dort auffällt, wo jemand HTML durch einen JSON-Parser schickt.

`app.Map("/api/{*rest}", 404)` davor. Buchstaben schlagen Platzhalter, also
gewinnt jeder echte Endpunkt; was übrig bleibt, ist nicht da und sagt das
auch.

350 Tests grün, die zwei neuen eingerechnet. Am Container nachgemessen: HEAD
auf den Download gibt 200 `video/mp4` mit 71 920 Byte, `/api/v1/gibtsnicht`
gibt 404, und ein Neuladen auf `/verwaltung` bekommt weiterhin die Seite.

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

220 lines
9.1 KiB
C#

using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Der Weg vom Scan bis zur heruntergeladenen Datei.</summary>
public class DeliveryTests
{
private string _bestand = "";
private static string? Werkzeug(string name) =>
(Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)
.Select(o => o.Length == 0 ? null : Path.Combine(o, name))
.FirstOrDefault(p => p is not null && File.Exists(p));
[SetUp]
public void Anlegen()
{
if (Werkzeug("ffmpeg") is null) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert.");
_bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-liefern-" + Guid.CreateVersion7().ToString("N"))).FullName;
}
[TearDown]
public void Aufraeumen()
{
try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
}
/// <summary>Eine Datei mit zwei Tonspuren, in der gewünschten Schachtel.</summary>
private string Film(string name, string endung)
{
var ziel = Path.Combine(_bestand, $"{name}.{endung}");
var start = new ProcessStartInfo(Werkzeug("ffmpeg")!) { RedirectStandardError = true, UseShellExecute = false };
foreach (var a in new[]
{
"-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-f", "lavfi", "-i", "sine=frequency=880:duration=1",
"-map", "0:v", "-map", "1:a", "-map", "2:a",
"-metadata:s:a:0", "language=deu", "-metadata:s:a:0", "title=Deutsch",
"-metadata:s:a:1", "language=deu", "-metadata:s:a:1", "title=Audiodeskription",
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", ziel,
}) start.ArgumentList.Add(a);
using var prozess = Process.Start(start)!;
var fehler = prozess.StandardError.ReadToEnd();
prozess.WaitForExit();
if (prozess.ExitCode != 0) throw new InvalidOperationException(fehler);
return ziel;
}
private async Task<(HttpClient Client, MediaFileResponse Datei)> Eingelesen()
{
var server = _server = new FlaroServer();
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", "Movies", _bestand));
angelegt.EnsureSuccessStatusCode();
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 150; i++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State == "finished") break;
await Task.Delay(50);
}
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items, Is.Not.Empty, "Der Scan hat nichts gefunden");
return (client, filme.Items[0].Files.Single());
}
private FlaroServer? _server;
[TearDown]
public void ServerSchliessen() { _server?.Dispose(); _server = null; }
/// <summary>Der Scan trägt die Spuren ein — darunter die beiden Namen.</summary>
[Test]
public async Task Die_Spuren_stehen_in_der_Mediathek()
{
Film("Zwei Spuren (2020)", "mkv");
var (_, datei) = await Eingelesen();
Assert.That(datei.Container, Is.EqualTo("mkv"));
Assert.That(datei.DurationSeconds, Is.EqualTo(1).Within(0.5));
var ton = datei.Streams.Where(s => s.Kind == "Audio").ToList();
Assert.That(ton, Has.Count.EqualTo(2));
Assert.That(ton.Select(t => t.Title), Is.EqualTo(new[] { "Deutsch", "Audiodeskription" }));
}
/// <summary>Der volle Pfad des Servers geht niemanden etwas an.</summary>
[Test]
public async Task Nur_der_Dateiname_kommt_heraus_nicht_der_Pfad()
{
Film("Pfadprobe (2020)", "mkv");
var (_, datei) = await Eingelesen();
Assert.That(datei.FileName, Is.EqualTo("Pfadprobe (2020).mkv"));
Assert.That(datei.FileName, Does.Not.Contain("/"));
}
[Test]
public async Task Eine_passende_MP4_geht_unveraendert_hinaus()
{
Film("Passend (2020)", "mp4");
var (client, datei) = await Eingelesen();
var auskunft = await client.GetFromJsonAsync<DeliveryInfo>($"/api/v1/files/{datei.Id}/delivery");
Assert.That(auskunft!.Method, Is.EqualTo("directFile"));
Assert.That(auskunft.PackageState, Is.EqualTo("notNeeded"));
var antwort = await client.GetAsync($"/api/v1/files/{datei.Id}/download");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(antwort.Content.Headers.ContentLength, Is.GreaterThan(0),
"Ohne Längenangabe gäbe es keinen Fortschrittsbalken");
}
/// <summary>Eine Matroska wird umgepackt — und der Client erfährt es vorher.</summary>
[Test]
public async Task Eine_Matroska_wird_angekuendigt_und_dann_umgepackt()
{
Film("Umzupacken (2020)", "mkv");
var (client, datei) = await Eingelesen();
var auskunft = await client.GetFromJsonAsync<DeliveryInfo>($"/api/v1/files/{datei.Id}/delivery");
Assert.That(auskunft!.Method, Is.EqualTo("remux"));
Assert.That(auskunft.Reasons, Does.Contain("containerUnsupported"));
Assert.That(auskunft.AudioToEncode, Is.Empty, "Beide Tonspuren passen und werden nur umgepackt");
// Der erste Abruf stösst an und wartet nicht.
var erster = await client.GetAsync($"/api/v1/files/{datei.Id}/download");
Assert.That(erster.StatusCode, Is.EqualTo(HttpStatusCode.Accepted));
HttpResponseMessage? fertig = null;
for (var i = 0; i < 150; i++)
{
fertig = await client.GetAsync($"/api/v1/files/{datei.Id}/download");
if (fertig.StatusCode != HttpStatusCode.Accepted) break;
await Task.Delay(100);
}
Assert.That(fertig!.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(fertig.Content.Headers.ContentLength, Is.GreaterThan(0));
Assert.That(fertig.Content.Headers.ContentDisposition?.FileNameStar ?? "",
Does.EndWith(".mp4"), "Heruntergeladen wird eine MP4, nicht eine MKV");
}
/// <summary>Fortsetzen nach einem Abbruch — die Forderung aus dem Fehlerbericht.</summary>
[Test]
public async Task Die_Auslieferung_versteht_Bereichsanfragen()
{
Film("Bereich (2020)", "mp4");
var (client, datei) = await Eingelesen();
var anfrage = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/files/{datei.Id}/download");
anfrage.Headers.Range = new RangeHeaderValue(100, 199);
var antwort = await client.SendAsync(anfrage);
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.PartialContent));
Assert.That((await antwort.Content.ReadAsByteArrayAsync()), Has.Length.EqualTo(100));
}
[Test]
public async Task Eine_Datei_die_es_nicht_gibt_ergibt_404()
{
Film("Egal (2020)", "mp4");
var (client, _) = await Eingelesen();
var antwort = await client.GetAsync($"/api/v1/files/{Guid.CreateVersion7()}/download");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
/// <summary>Der Client fragt mit HEAD nach, ob das Umpacken fertig ist.</summary>
///
/// <remarks>
/// <b>Der Fehler, den erst der Browser zeigte.</b> <c>MapGet</c> nimmt
/// HEAD nicht an. Die Anfrage fiel durch bis zum SPA-Rückfall, bekam
/// <c>200 text/html</c>, und der Spieler hielt die Webseite für den Film:
/// „MEDIA_ELEMENT_ERROR: Format error", 531 Byte lang.
/// </remarks>
[Test]
public async Task HEAD_auf_den_Download_antwortet_mit_dem_Download()
{
Film("Kopfpruefung (2021)", "mp4");
var (client, datei) = await Eingelesen();
var antwort = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Head, $"/api/v1/files/{datei.Id}/download"));
Assert.Multiple(() =>
{
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(antwort.Content.Headers.ContentType?.MediaType, Is.EqualTo("video/mp4"));
});
}
[Test]
public async Task Ohne_Anmeldung_gibt_es_nichts()
{
Film("Geschuetzt (2020)", "mp4");
var (_, datei) = await Eingelesen();
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
// den Angemeldeten — und bestünde nie wieder.
var antwort = await _server!.OhneGedaechtnis().GetAsync($"/api/v1/files/{datei.Id}/download");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
}