Stufe 2b: Metadaten von TMDB — und ein Testlauf, der 19-mal schneller ist

Titel, Beschreibungen und Bilder kommen jetzt von TMDB. Dazu zwei Fehler, die
erst das Zusammenspiel zeigte.

**Der Anbieter steckt hinter einer Schnittstelle, und das ist kein Vorrat für
einen zweiten.** Es geht darum, dass alles, was darauf aufbaut, *ohne Netz und
ohne Schlüssel* prüfbar bleibt. Ein Projekt, dessen Tests einen fremden Dienst
brauchen, hat am Ende keine Tests: sie scheitern, wenn das WLAN hakt, und
irgendwann schaltet sie jemand ab.

**Ein Fehlschlag bei TMDB ist kein Abbruch.** Kein Netz, Kontingent erschöpft,
ein Anmeldeportal im Hotel-WLAN, das HTML mit Status 200 schickt — in allen
Fällen bleibt der Titel beim Dateinamen und der Scan läuft durch. Die eine
Ausnahme ist ein zurückgewiesener Schlüssel: „nichts gefunden" und „dein
Schlüssel ist falsch" sehen in der Mediathek gleich aus, und ohne diese
Unterscheidung sucht jemand stundenlang. Der Schlüssel steht im Scanbericht.

**Der Schlüssel kommt nie wieder heraus.** Abgefragt wird nur, ob einer da
ist — sonst gäbe die Oberfläche jedem Angemeldeten das TMDB-Kontingent des
Betreibers in die Hand. Geprüft, dass er nicht einmal versehentlich im
Antwortkörper steht.

**Bilder holt die App bei Halo, nicht bei TMDB.** Sonst bräuchte sie Internet
für ein Plakat, das zehn Zentimeter weiter auf der Platte liegt — und TMDB
erführe, wer wann was ansieht. Der Schlüssel ist der Hash der Quelladresse,
also lädt dasselbe Plakat nie zweimal. Dass daraus kein Weg ins Dateisystem
wird, ist eigens geprüft: ohne die Formprüfung wäre
`/api/v1/images/../../config/halo.db` ein Weg, die Datenbank samt aller
Token-Fingerabdrücke herunterzuladen.

---

**Der erste Fehler: nach dem Eintragen der Metadaten legte der nächste Scan
den Film erneut an.** Zugeordnet wurde über den Titel — und den hatte TMDB
gerade von „The Matrix" auf „Matrix" geändert. Beim übernächsten Lauf dasselbe
noch einmal. Das ist die Sorte Fehler, die man erst nach Wochen bemerkt, wenn
jeder Film fünfmal dasteht. Jetzt gibt es `ParsedTitle`: den Titel aus dem
Dateinamen, der sich nie ändert, und nur der entscheidet beim Zuordnen.

**Der zweite Fehler kostete 15 Minuten je Testlauf.** Zwei Tests löschen die
Pfadvariablen, um den Rückfall auf die Voreinstellung zu prüfen — und liessen
sie gelöscht zurück. Jeder danach gestartete Testserver fiel dann auf
`<Projektverzeichnis>/data` zurück, und Dutzende Server teilten sich **eine**
SQLite-Datei, auf die sie der Reihe nach warteten. Dazu wartete jeder Host
beim Herunterfahren die vollen fünf Sekunden.

Gefunden durch Halbieren, nicht durch Raten: einzeln war jede Testklasse
schnell, zusammen nicht. Der Beweis lag am Ende als leeres Verzeichnis
`src/Halo.Api/data` auf der Platte.

**15 Minuten 32 → 49 Sekunden.** 180 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 16:52:46 +02:00
co-authored by Claude Opus 5
parent 78b7e3bcb8
commit acfe2668a8
32 changed files with 2575 additions and 38 deletions
+31 -9
View File
@@ -3,20 +3,44 @@ namespace Halo.Api.Tests;
/// <summary>Was beim allerersten Start passiert, bevor irgendetwas anderes greift.</summary>
public class HaloPathsTests
{
private string _wurzel = "";
private static readonly string[] Variablen = ["HALO_DATA", "HALO_CACHE", "HALO_TRANSCODE"];
private string _wurzel = "";
private Dictionary<string, string?> _vorher = [];
/// <summary>Merkt sich die Umgebung, bevor diese Klasse daran rührt.</summary>
///
/// <remarks>
/// <b>Ohne das kostete der ganze Lauf eine Viertelstunde.</b> Zwei Tests
/// hier löschen die Pfadvariablen, um den Rückfall auf die Voreinstellung
/// zu prüfen — und liessen sie gelöscht zurück. Jeder danach gestartete
/// Testserver fiel dann auf <c>&lt;Projektverzeichnis&gt;/data</c> zurück,
/// und Dutzende Server teilten sich **eine** SQLite-Datei. Die warteten
/// dann aufeinander, statt zu prüfen.
///
/// Prozessweiter Zustand gehört zurückgesetzt, wo er verändert wurde. Das
/// ist keine Formsache: der Schaden zeigt sich erst in einer anderen Klasse.
/// </remarks>
[SetUp]
public void Anlegen() =>
public void Anlegen()
{
_vorher = Variablen.ToDictionary(v => v, Environment.GetEnvironmentVariable);
_wurzel = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "halo-pfade-" + Guid.CreateVersion7().ToString("N"))).FullName;
}
[TearDown]
public void Aufraeumen()
{
// Zurücksetzen, sonst lässt sich das gesperrte Verzeichnis nicht löschen.
foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories))
File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
if (!OperatingSystem.IsWindows())
{
foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories))
File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
foreach (var (name, wert) in _vorher) Environment.SetEnvironmentVariable(name, wert);
}
private HaloPaths Pfade(string? daten = null) => new()
@@ -61,6 +85,7 @@ public class HaloPathsTests
if (OperatingSystem.IsWindows()) Assert.Ignore("Dateirechte verhalten sich dort anders.");
var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "gesperrt")).FullName;
if (OperatingSystem.IsWindows()) return;
File.SetUnixFileMode(gesperrt, UnixFileMode.UserRead | UnixFileMode.UserExecute);
var fehler = Assert.Throws<InvalidOperationException>(() => Pfade(daten: gesperrt).EnsureExists());
@@ -87,10 +112,7 @@ public class HaloPathsTests
public void Die_Umgebung_sticht_die_Voreinstellung()
{
Environment.SetEnvironmentVariable("HALO_DATA", "/woanders");
try
{
Assert.That(HaloPaths.FromEnvironment(_wurzel).Data, Is.EqualTo("/woanders"));
}
finally { Environment.SetEnvironmentVariable("HALO_DATA", null); }
Assert.That(HaloPaths.FromEnvironment(_wurzel).Data, Is.EqualTo("/woanders"));
// Zurückgesetzt wird im TearDown, für alle drei Variablen gemeinsam.
}
}
+19 -2
View File
@@ -2,6 +2,8 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using Halo.Api.Endpoints;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Halo.Api.Tests;
@@ -15,12 +17,13 @@ namespace Halo.Api.Tests;
/// </remarks>
public sealed class HaloServer : IDisposable
{
private readonly WebApplicationFactory<Program> _grundlage;
private readonly WebApplicationFactory<Program> _fabrik;
private readonly string _verzeichnis;
public HttpClient Client { get; }
public HaloServer()
public HaloServer(Action<IServiceCollection>? dienste = null)
{
_verzeichnis = Path.Combine(Path.GetTempPath(), "halo-test-" + Guid.CreateVersion7().ToString("N"));
Environment.SetEnvironmentVariable("HALO_DATA", Path.Combine(_verzeichnis, "config"));
@@ -31,7 +34,17 @@ public sealed class HaloServer : IDisposable
// Anmeldung.
Environment.SetEnvironmentVariable("HALO_RATELIMIT_SIGNIN", "10000");
_fabrik = new WebApplicationFactory<Program>();
_grundlage = new WebApplicationFactory<Program>();
_fabrik = _grundlage.WithWebHostBuilder(b => b.ConfigureServices(sammlung =>
{
// **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host
// wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und
// bei einem Testserver je Test summiert sich das auf mehr Zeit,
// als sämtliche Prüfungen zusammen brauchen. Hier gibt es nichts,
// worauf zu warten wäre.
sammlung.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(50));
dienste?.Invoke(sammlung);
}));
Client = _fabrik.CreateClient();
}
@@ -62,6 +75,10 @@ public sealed class HaloServer : IDisposable
public void Dispose()
{
_fabrik.Dispose();
// Auch die Grundlage: `WithWebHostBuilder` gibt eine neue Fabrik
// zurück und lässt die alte stehen. Wer nur die neue entsorgt, häuft
// je Test einen Host an, der erst beim Prozessende abgeräumt wird.
_grundlage.Dispose();
try { Directory.Delete(_verzeichnis, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
}
}
@@ -0,0 +1,340 @@
using System.Net;
using System.Net.Http.Json;
using Halo.Api.Endpoints;
using Halo.Core.Metadata;
using Microsoft.Extensions.DependencyInjection;
namespace Halo.Api.Tests;
/// <summary>Ein Anbieter, der antwortet, was der Test vorgibt.</summary>
public sealed class VorgegebenerAnbieter : IMetadataProvider
{
public bool Konfiguriert { get; set; } = true;
public Exception? Wirft { get; set; }
public MovieMetadata? Film { get; set; }
public SeriesMetadata? Serie { get; set; }
public EpisodeMetadata? Folge { get; set; }
public List<string> Gefragt { get; } = [];
public Task<bool> IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(Konfiguriert);
public Task<MovieMetadata?> FindMovieAsync(string titel, int? jahr, CancellationToken ct = default)
{
Gefragt.Add($"film:{titel}:{jahr}");
if (Wirft is not null) throw Wirft;
return Task.FromResult(Film);
}
public Task<SeriesMetadata?> FindSeriesAsync(string titel, int? jahr, CancellationToken ct = default)
{
Gefragt.Add($"serie:{titel}");
if (Wirft is not null) throw Wirft;
return Task.FromResult(Serie);
}
public Task<EpisodeMetadata?> FindEpisodeAsync(int serie, int staffel, int folge, CancellationToken ct = default)
{
Gefragt.Add($"folge:{serie}:{staffel}:{folge}");
if (Wirft is not null) throw Wirft;
return Task.FromResult(Folge);
}
}
/// <summary>Eine Bildablage, die nichts herunterlädt.</summary>
public sealed class VorgegebeneBilder : IImageStore
{
public List<string> Geholt { get; } = [];
public Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default)
{
Geholt.Add(bild.Url);
return Task.FromResult<string?>(new string('a', 64));
}
}
public class MetadataTests
{
private string _bestand = "";
[SetUp]
public void Anlegen() => _bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "halo-meta-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
}
private void Datei(string relativ)
{
var voll = Path.Combine(_bestand, relativ);
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
File.WriteAllBytes(voll, new byte[1024]);
}
private HaloServer MitAnbieter(VorgegebenerAnbieter anbieter, VorgegebeneBilder? bilder = null) =>
new(dienste =>
{
dienste.AddScoped<IMetadataProvider>(_ => anbieter);
dienste.AddScoped<IImageStore>(_ => bilder ?? new VorgegebeneBilder());
});
private async Task<(HttpClient Admin, Guid Library)> Einrichten(HaloServer server, string art)
{
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", art, _bestand));
angelegt.EnsureSuccessStatusCode();
return (client, (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!.Id);
}
private static async Task<ScanStatus> Scannen(HttpClient client, Guid bibliothek)
{
(await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode();
for (var i = 0; i < 100; i++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
if (stand!.State == "finished") return stand;
if (stand.State == "failed") Assert.Fail($"Gescheitert: {stand.Error}");
await Task.Delay(50);
}
Assert.Fail("Nicht fertig geworden.");
return null!;
}
[Test]
public async Task Der_Titel_aus_der_Quelle_ersetzt_den_aus_dem_Dateinamen()
{
Datei("The.Matrix.1999.1080p.mkv");
var anbieter = new VorgegebenerAnbieter
{
Film = new MovieMetadata(603, "Matrix", 1999, "Neo zweifelt.",
new RemoteImage("https://bild/p.jpg"), new RemoteImage("https://bild/b.jpg")),
};
var bilder = new VorgegebeneBilder();
using var server = MitAnbieter(anbieter, bilder);
var (client, bibliothek) = await Einrichten(server, "Movies");
var stand = await Scannen(client, bibliothek);
Assert.That(stand.Metadata!.MoviesMatched, Is.EqualTo(1));
Assert.That(anbieter.Gefragt, Does.Contain("film:The Matrix:1999"));
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items[0].Title, Is.EqualTo("Matrix"));
Assert.That(filme.Items[0].Overview, Is.EqualTo("Neo zweifelt."));
Assert.That(filme.Items[0].PosterImage, Is.Not.Null);
Assert.That(bilder.Geholt, Is.EquivalentTo(new[] { "https://bild/p.jpg", "https://bild/b.jpg" }));
}
/// <summary>Was die Quelle nicht kennt, bleibt trotzdem in der Mediathek.</summary>
[Test]
public async Task Ein_Film_ohne_Treffer_behaelt_seinen_Dateinamen()
{
Datei("Urlaub Kreta 2019.mkv");
using var server = MitAnbieter(new VorgegebenerAnbieter { Film = null });
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Total, Is.EqualTo(1));
Assert.That(filme.Items[0].Title, Is.EqualTo("Urlaub Kreta"));
}
/// <summary>Ohne Schlüssel läuft der Scan trotzdem durch.</summary>
///
/// <remarks>
/// Das ist der Normalfall beim allerersten Start: Bibliothek angelegt,
/// Schlüssel noch nicht. Die Mediathek muss dann aus Dateinamen bestehen
/// und nicht leer sein.
/// </remarks>
[Test]
public async Task Ohne_Schluessel_bleibt_es_bei_den_Dateinamen()
{
Datei("The Matrix (1999).mkv");
var anbieter = new VorgegebenerAnbieter { Konfiguriert = false };
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
var stand = await Scannen(client, bibliothek);
Assert.That(stand.Metadata!.Skipped, Is.True);
Assert.That(anbieter.Gefragt, Is.Empty);
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total, Is.EqualTo(1));
}
/// <summary>Ein falscher Schlüssel steht im Scanbericht, nicht nur im Protokoll.</summary>
[Test]
public async Task Ein_zurueckgewiesener_Schluessel_erscheint_im_Bericht()
{
Datei("The Matrix (1999).mkv");
var anbieter = new VorgegebenerAnbieter
{
Wirft = new MetadataCredentialsException("TMDB weist den hinterlegten Schlüssel zurück."),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
var stand = await Scannen(client, bibliothek);
Assert.That(stand.Metadata!.Error, Does.Contain("Schlüssel"));
// Die Datei ist trotzdem da — das Nachschlagen scheiterte, nicht der Scan.
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total, Is.EqualTo(1));
}
[Test]
public async Task Ein_zweiter_Lauf_fragt_nicht_noch_einmal()
{
Datei("The Matrix (1999).mkv");
var anbieter = new VorgegebenerAnbieter
{
Film = new MovieMetadata(603, "Matrix", 1999, "Neo.", null, null),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Movies");
await Scannen(client, bibliothek);
var anzahlNachErstem = anbieter.Gefragt.Count;
await Scannen(client, bibliothek);
Assert.That(anbieter.Gefragt, Has.Count.EqualTo(anzahlNachErstem),
"Ein zugeordneter Titel wird nicht erneut nachgeschlagen");
}
[Test]
public async Task Serien_und_Folgen_werden_nachgeschlagen()
{
Datei("Dark/Dark - S01E01.mkv");
var anbieter = new VorgegebenerAnbieter
{
Serie = new SeriesMetadata(70523, "Dark", 2017, "Vier Familien.", null, null),
Folge = new EpisodeMetadata("Geheimnisse", "Ein Junge verschwindet.", null),
};
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Shows");
var stand = await Scannen(client, bibliothek);
Assert.That(stand.Metadata!.SeriesMatched, Is.EqualTo(1));
Assert.That(stand.Metadata.EpisodesMatched, Is.EqualTo(1));
Assert.That(anbieter.Gefragt, Does.Contain("folge:70523:1:1"));
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
Assert.That(serien!.Items[0].Overview, Is.EqualTo("Vier Familien."));
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien.Items[0].Id}/episodes");
Assert.That(folgen!.Items[0].Title, Is.EqualTo("Geheimnisse"));
Assert.That(folgen.Items[0].Overview, Is.EqualTo("Ein Junge verschwindet."));
}
/// <summary>Folgen einer Serie ohne Zuordnung werden gar nicht erst gefragt.</summary>
[Test]
public async Task Ohne_zugeordnete_Serie_werden_keine_Folgen_gefragt()
{
Datei("Unbekannt/Unbekannt - S01E01.mkv");
var anbieter = new VorgegebenerAnbieter { Serie = null };
using var server = MitAnbieter(anbieter);
var (client, bibliothek) = await Einrichten(server, "Shows");
await Scannen(client, bibliothek);
Assert.That(anbieter.Gefragt.Where(g => g.StartsWith("folge:")), Is.Empty);
}
}
public class MetadataSettingsTests
{
/// <summary>Der Schlüssel darf nie zurückkommen.</summary>
///
/// <remarks>
/// Er gäbe jedem Angemeldeten das TMDB-Kontingent des Betreibers in die
/// Hand. Zum Anzeigen genügt die Auskunft, dass einer da ist.
/// </remarks>
[Test]
public async Task Der_Schluessel_kommt_nicht_zurueck()
{
using var server = new HaloServer();
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
await client.PutAsJsonAsync("/api/v1/settings/metadata",
new MetadataSettingsRequest("geheimer-schluessel", "de-DE"));
var antwort = await client.GetAsync("/api/v1/settings/metadata");
var roh = await antwort.Content.ReadAsStringAsync();
Assert.That(roh, Does.Not.Contain("geheimer-schluessel"));
var stand = await antwort.Content.ReadFromJsonAsync<MetadataSettingsResponse>();
Assert.That(stand!.HasApiKey, Is.True);
Assert.That(stand.Language, Is.EqualTo("de-DE"));
}
[Test]
public async Task Ohne_Schluessel_sagt_der_Server_das_auch()
{
using var server = new HaloServer();
var admin = await server.SetUpAndSignIn();
var stand = await server.AlsAngemeldeter(admin.Token)
.GetFromJsonAsync<MetadataSettingsResponse>("/api/v1/settings/metadata");
Assert.That(stand!.HasApiKey, Is.False);
Assert.That(stand.Language, Is.EqualTo("en-US"));
}
[TestCase("deutsch")]
[TestCase("de_DE")]
[TestCase("DE")]
[TestCase("de-de")]
public async Task Eine_unsinnige_Sprachkennung_wird_abgelehnt(string sprache)
{
using var server = new HaloServer();
var admin = await server.SetUpAndSignIn();
var antwort = await server.AlsAngemeldeter(admin.Token)
.PutAsJsonAsync("/api/v1/settings/metadata", new MetadataSettingsRequest(null, sprache));
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Ein_gewoehnlicher_Benutzer_sieht_die_Einstellungen_nicht()
{
using var server = new HaloServer();
var admin = await server.SetUpAndSignIn();
await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/users",
new CreateUserRequest("gast", "auch-ein-langes-passwort", false));
var gast = await server.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-1");
var antwort = await server.AlsAngemeldeter(gast.Token).GetAsync("/api/v1/settings/metadata");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
}
}
public class ImageEndpointTests
{
[Test]
public async Task Bilder_gibt_es_nur_fuer_Angemeldete()
{
using var server = new HaloServer();
await server.SetUpAndSignIn();
var antwort = await server.Client.GetAsync($"/api/v1/images/{new string('a', 64)}");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Der Weg, der sonst die Datenbank herausgäbe.</summary>
[TestCase("..%2F..%2Fconfig%2Fhalo.db")]
[TestCase("nichtsechzigvierzeichen")]
[TestCase("ZZZZ567890123456789012345678901234567890123456789012345678901234")]
public async Task Ein_Schluessel_der_keiner_ist_fuehrt_ins_Leere(string versuch)
{
using var server = new HaloServer();
var admin = await server.SetUpAndSignIn();
var antwort = await server.AlsAngemeldeter(admin.Token).GetAsync($"/api/v1/images/{versuch}");
Assert.That(antwort.StatusCode, Is.AnyOf(HttpStatusCode.NotFound, HttpStatusCode.BadRequest));
}
}
@@ -13,8 +13,13 @@ public class LibraryWalkerTests
[TearDown]
public void Aufraeumen()
{
foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories))
File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
// Ein Test sperrt absichtlich einen Ordner; ohne Zurücksetzen liesse
// er sich nicht mehr löschen.
if (!OperatingSystem.IsWindows())
{
foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories))
File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
}
@@ -94,6 +99,7 @@ public class LibraryWalkerTests
Datei("Filme/Erreichbar (2000).mkv");
var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "Filme", "gesperrt")).FullName;
File.WriteAllBytes(Path.Combine(gesperrt, "Versteckt (2001).mkv"), new byte[10]);
if (OperatingSystem.IsWindows()) return;
File.SetUnixFileMode(gesperrt, UnixFileMode.None);
var ergebnis = LibraryWalker.Walk(_wurzel);
@@ -0,0 +1,46 @@
using System.Net;
using System.Text;
namespace Halo.Metadata.Tests;
/// <summary>Ein HTTP-Gegenüber, das antwortet, was der Test vorgibt.</summary>
///
/// <remarks>
/// <b>Damit die Prüfungen ohne Netz und ohne Schlüssel laufen.</b> Ein
/// Projekt, dessen Tests einen fremden Dienst brauchen, hat am Ende keine
/// Tests: sie scheitern, wenn das WLAN hakt, und irgendwann schaltet sie
/// jemand ab.
/// </remarks>
public sealed class Attrappe(Func<HttpRequestMessage, HttpResponseMessage> antwort) : HttpMessageHandler
{
public List<string> Aufrufe { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage anfrage, CancellationToken ct)
{
// AbsoluteUri und nicht ToString(): Letzteres entschlüsselt zur
// Anzeige wieder, und dann sähe eine richtig kodierte Anfrage falsch
// aus — der Test prüfte eine Darstellung statt dessen, was hinausgeht.
Aufrufe.Add(anfrage.RequestUri!.AbsoluteUri);
return Task.FromResult(antwort(anfrage));
}
public static Attrappe MitJson(string json, HttpStatusCode code = HttpStatusCode.OK) =>
new(_ => new HttpResponseMessage(code)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
public static Attrappe MitStatus(HttpStatusCode code) => new(_ => new HttpResponseMessage(code));
public static Attrappe DieWirft(Exception fehler) => new(_ => throw fehler);
}
public sealed record FesteEinstellungen(TmdbSettings Werte) : ITmdbSettingsSource
{
public Task<TmdbSettings> GetAsync(CancellationToken ct = default) => Task.FromResult(Werte);
public static FesteEinstellungen MitSchluessel(string sprache = "de-DE") =>
new(new TmdbSettings("ein-schluessel", sprache));
public static readonly FesteEinstellungen Ohne = new(TmdbSettings.Unconfigured);
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Halo.Metadata\Halo.Metadata.csproj" />
<ProjectReference Include="..\..\src\Halo.Core\Halo.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,155 @@
using System.Net;
using System.Text;
using Halo.Core.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
namespace Halo.Metadata.Tests;
public class ImageCacheTests
{
private string _wurzel = "";
[SetUp]
public void Anlegen() => _wurzel = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "halo-bilder-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
}
private ImageCache Ablage(Attrappe attrappe) =>
new(new HttpClient(attrappe), new ImageCacheOptions(_wurzel), NullLogger<ImageCache>.Instance);
private static Attrappe MitBild(byte[]? inhalt = null) => new(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(inhalt ?? Encoding.UTF8.GetBytes("ein bild")),
});
[Test]
public async Task Ein_Bild_wird_abgelegt_und_liegt_danach_da()
{
var ablage = Ablage(MitBild());
var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg"));
Assert.That(schlüssel, Is.Not.Null);
Assert.That(schlüssel, Has.Length.EqualTo(64));
Assert.That(File.Exists(ablage.PfadFür(schlüssel!)), Is.True);
}
/// <summary>Dasselbe Bild wird nicht zweimal geholt.</summary>
///
/// <remarks>
/// Bei einer Serie mit zehn Staffeln hängt dasselbe Plakat an jeder — ohne
/// diese Prüfung lüde der Server es zehnmal.
/// </remarks>
[Test]
public async Task Dieselbe_Adresse_laedt_nur_einmal()
{
var attrappe = MitBild();
var ablage = Ablage(attrappe);
var erster = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg"));
var zweiter = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg"));
Assert.That(zweiter, Is.EqualTo(erster));
Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(1));
}
[Test]
public async Task Verschiedene_Adressen_ergeben_verschiedene_Schluessel()
{
var ablage = Ablage(MitBild());
var a = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg"));
var b = await ablage.StoreAsync(new RemoteImage("https://bild.example/b.jpg"));
Assert.That(a, Is.Not.EqualTo(b));
}
/// <summary>Die Prüfung, ohne die die Adresszeile ein Weg in den Container wäre.</summary>
///
/// <remarks>
/// <b>Die wichtigste Prüfung dieser Datei.</b> Der Schlüssel kommt aus
/// einer URL. Liesse er Pfadangaben durch, wäre
/// <c>/api/v1/images/../../config/halo.db</c> ein Weg, die Datenbank
/// herunterzuladen — samt aller Token-Fingerabdrücke.
/// </remarks>
[TestCase("../../config/halo.db")]
[TestCase("..%2f..%2fconfig")]
[TestCase("/etc/passwd")]
[TestCase("abc")]
[TestCase("")]
[TestCase("GROSSBUCHSTABEN0123456789012345678901234567890123456789012345678")]
[TestCase("zzzz567890123456789012345678901234567890123456789012345678901234")]
public void Ein_Schluessel_der_keiner_ist_fuehrt_nirgendwohin(string versuch)
{
Assert.That(Ablage(MitBild()).PfadFür(versuch), Is.Null);
}
[Test]
public async Task Ein_echter_Schluessel_fuehrt_unter_die_Wurzel()
{
var ablage = Ablage(MitBild());
var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg"));
var pfad = Path.GetFullPath(ablage.PfadFür(schlüssel!)!);
Assert.That(pfad, Does.StartWith(Path.GetFullPath(_wurzel)));
}
[Test]
public async Task Ein_Fehlschlag_legt_nichts_ab()
{
var ablage = Ablage(Attrappe.MitStatus(HttpStatusCode.NotFound));
Assert.That(await ablage.StoreAsync(new RemoteImage("https://bild.example/weg.jpg")), Is.Null);
}
[Test]
public async Task Kein_Netz_wirft_nicht()
{
var ablage = Ablage(Attrappe.DieWirft(new HttpRequestException("kein Netz")));
Assert.That(await ablage.StoreAsync(new RemoteImage("https://bild.example/a.jpg")), Is.Null);
}
/// <summary>Nach einem Abbruch darf keine halbe Datei unter dem Schlüssel liegen.</summary>
///
/// <remarks>
/// Sie würde nie wieder nachgeladen — der Schlüssel ist ja vorhanden — und
/// das Plakat bliebe für immer kaputt.
/// </remarks>
[Test]
public async Task Ein_abgebrochener_Download_hinterlaesst_keine_halbe_Datei()
{
var ablage = Ablage(new Attrappe(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(new AbbrechenderStrom()),
}));
var schlüssel = await ablage.StoreAsync(new RemoteImage("https://bild.example/kaputt.jpg"));
Assert.That(schlüssel, Is.Null);
var übrig = Directory.Exists(Path.Combine(_wurzel, "images"))
? Directory.GetFiles(Path.Combine(_wurzel, "images"), "*", SearchOption.AllDirectories)
: [];
Assert.That(übrig.Where(f => !f.EndsWith(".teil")), Is.Empty);
}
private sealed class AbbrechenderStrom : Stream
{
private int _gelesen;
public override int Read(byte[] puffer, int versatz, int anzahl)
{
if (_gelesen++ > 0) throw new IOException("Verbindung abgebrochen");
puffer[versatz] = 1;
return 1;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => 0; set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long v, SeekOrigin o) => throw new NotSupportedException();
public override void SetLength(long v) => throw new NotSupportedException();
public override void Write(byte[] p, int v, int a) => throw new NotSupportedException();
}
}
@@ -0,0 +1,163 @@
using System.Net;
using Halo.Core.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
namespace Halo.Metadata.Tests;
public class TmdbClientTests
{
private static TmdbClient Client(Attrappe attrappe, ITmdbSettingsSource? einstellungen = null) =>
new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) },
einstellungen ?? FesteEinstellungen.MitSchluessel(),
NullLogger<TmdbClient>.Instance);
private const string FilmTreffer = """
{"results":[{"id":603,"title":"Matrix","release_date":"1999-03-30",
"overview":"Neo zweifelt an der Wirklichkeit.",
"poster_path":"/abc.jpg","backdrop_path":"/def.jpg"}]}
""";
[Test]
public async Task Ohne_Schluessel_wird_nichts_gefragt()
{
var attrappe = Attrappe.MitJson(FilmTreffer);
var client = Client(attrappe, FesteEinstellungen.Ohne);
Assert.That(await client.IsConfiguredAsync(), Is.False);
Assert.That(await client.FindMovieAsync("Matrix", 1999), Is.Null);
Assert.That(attrappe.Aufrufe, Is.Empty, "Ohne Schlüssel darf keine Anfrage hinausgehen");
}
[Test]
public async Task Ein_Film_wird_gelesen()
{
var film = await Client(Attrappe.MitJson(FilmTreffer)).FindMovieAsync("Matrix", 1999);
Assert.That(film, Is.Not.Null);
Assert.That(film!.ProviderId, Is.EqualTo(603));
Assert.That(film.Title, Is.EqualTo("Matrix"));
Assert.That(film.Year, Is.EqualTo(1999));
Assert.That(film.Overview, Does.StartWith("Neo zweifelt"));
Assert.That(film.Poster!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w500/abc.jpg"));
Assert.That(film.Backdrop!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w1280/def.jpg"));
}
[Test]
public async Task Titel_Jahr_und_Sprache_landen_in_der_Anfrage()
{
var attrappe = Attrappe.MitJson(FilmTreffer);
await Client(attrappe, FesteEinstellungen.MitSchluessel("de-DE")).FindMovieAsync("Der Untergang", 2004);
var aufruf = attrappe.Aufrufe.Single();
Assert.That(aufruf, Does.Contain("query=Der%20Untergang"));
Assert.That(aufruf, Does.Contain("year=2004"));
Assert.That(aufruf, Does.Contain("language=de-DE"));
}
[Test]
public async Task Ohne_Jahr_wird_keines_mitgeschickt()
{
var attrappe = Attrappe.MitJson(FilmTreffer);
await Client(attrappe).FindMovieAsync("Matrix", null);
Assert.That(attrappe.Aufrufe.Single(), Does.Not.Contain("year="));
}
[Test]
public async Task Eine_leere_Trefferliste_ergibt_nichts()
{
Assert.That(await Client(Attrappe.MitJson("""{"results":[]}""")).FindMovieAsync("Gibtesnicht", null), Is.Null);
}
/// <summary>Ein falscher Schlüssel muss auffallen.</summary>
///
/// <remarks>
/// <b>„Nichts gefunden" und „dein Schlüssel ist falsch" sehen in der
/// Mediathek gleich aus.</b> Ohne diese Unterscheidung sucht jemand
/// stundenlang nach dem Grund, warum keine Bilder erscheinen.
/// </remarks>
[TestCase(HttpStatusCode.Unauthorized)]
[TestCase(HttpStatusCode.Forbidden)]
public void Ein_zurueckgewiesener_Schluessel_wirft(HttpStatusCode code)
{
var client = Client(Attrappe.MitStatus(code));
var fehler = Assert.ThrowsAsync<MetadataCredentialsException>(() => client.FindMovieAsync("Matrix", null));
Assert.That(fehler!.Message, Does.Contain("Schlüssel"));
}
/// <summary>Alles andere ist kein Grund, den Scan abzubrechen.</summary>
[TestCase(HttpStatusCode.NotFound)]
[TestCase(HttpStatusCode.InternalServerError)]
[TestCase(HttpStatusCode.TooManyRequests)]
[TestCase(HttpStatusCode.ServiceUnavailable)]
public async Task Andere_Fehler_ergeben_still_nichts(HttpStatusCode code)
{
Assert.That(await Client(Attrappe.MitStatus(code)).FindMovieAsync("Matrix", null), Is.Null);
}
/// <summary>Ein Anmeldeportal im Hotel-WLAN antwortet mit HTML und Status 200.</summary>
///
/// <remarks>
/// Darauf mit einer Ausnahme zu reagieren hiesse, den ganzen Scan an einem
/// fremden Netz scheitern zu lassen.
/// </remarks>
[Test]
public async Task Unsinn_statt_JSON_wirft_nicht()
{
var attrappe = Attrappe.MitJson("<html><body>Bitte anmelden</body></html>");
Assert.That(await Client(attrappe).FindMovieAsync("Matrix", null), Is.Null);
}
[Test]
public async Task Kein_Netz_ergibt_nichts_statt_eines_Absturzes()
{
var attrappe = Attrappe.DieWirft(new HttpRequestException("Name or service not known"));
Assert.That(await Client(attrappe).FindMovieAsync("Matrix", null), Is.Null);
}
[Test]
public async Task Eine_Serie_wird_gelesen()
{
var json = """
{"results":[{"id":70523,"name":"Dark","first_air_date":"2017-12-01",
"overview":"Vier Familien.","poster_path":"/p.jpg","backdrop_path":null}]}
""";
var serie = await Client(Attrappe.MitJson(json)).FindSeriesAsync("Dark", null);
Assert.That(serie!.ProviderId, Is.EqualTo(70523));
Assert.That(serie.Title, Is.EqualTo("Dark"));
Assert.That(serie.Year, Is.EqualTo(2017));
Assert.That(serie.Poster!.Url, Does.EndWith("/p.jpg"));
Assert.That(serie.Backdrop, Is.Null, "Ein fehlendes Bild ist null, keine kaputte Adresse");
}
[Test]
public async Task Eine_Folge_wird_gelesen()
{
var attrappe = Attrappe.MitJson("""
{"name":"Geheimnisse","overview":"Ein Junge verschwindet.","still_path":"/s.jpg"}
""");
var folge = await Client(attrappe).FindEpisodeAsync(70523, 1, 1);
Assert.That(folge!.Title, Is.EqualTo("Geheimnisse"));
Assert.That(folge.Still!.Url, Is.EqualTo("https://image.tmdb.org/t/p/w300/s.jpg"));
Assert.That(attrappe.Aufrufe.Single(), Does.Contain("tv/70523/season/1/episode/1"));
}
/// <summary>Eine Folge, die TMDB nicht kennt, ist kein Fehler.</summary>
[Test]
public async Task Eine_unbekannte_Folge_ergibt_nichts()
{
Assert.That(await Client(Attrappe.MitStatus(HttpStatusCode.NotFound)).FindEpisodeAsync(1, 99, 99), Is.Null);
}
[Test]
public async Task Leere_Felder_werden_zu_null_statt_zu_Leerstrings()
{
var json = """{"results":[{"id":1,"title":"Ohne","release_date":"","overview":"","poster_path":""}]}""";
var film = await Client(Attrappe.MitJson(json)).FindMovieAsync("Ohne", null);
Assert.That(film!.Overview, Is.Null);
Assert.That(film.Year, Is.Null);
Assert.That(film.Poster, Is.Null);
}
}