using System.Net; using System.Net.Http.Json; using Halo.Api.Endpoints; using Halo.Core.Metadata; using Microsoft.Extensions.DependencyInjection; namespace Halo.Api.Tests; /// Ein Anbieter, der antwortet, was der Test vorgibt. 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 Gefragt { get; } = []; public Task IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(Konfiguriert); public Task 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 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 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); } } /// Eine Bildablage, die nichts herunterlädt. public sealed class VorgegebeneBilder : IImageStore { public List Geholt { get; } = []; public Task StoreAsync(RemoteImage bild, CancellationToken ct = default) { Geholt.Add(bild.Url); return Task.FromResult(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(_ => anbieter); dienste.AddScoped(_ => 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())!.Id); } private static async Task 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($"/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>("/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" })); } /// Was die Quelle nicht kennt, bleibt trotzdem in der Mediathek. [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>("/api/v1/movies"); Assert.That(filme!.Total, Is.EqualTo(1)); Assert.That(filme.Items[0].Title, Is.EqualTo("Urlaub Kreta")); } /// Ohne Schlüssel läuft der Scan trotzdem durch. /// /// /// Das ist der Normalfall beim allerersten Start: Bibliothek angelegt, /// Schlüssel noch nicht. Die Mediathek muss dann aus Dateinamen bestehen /// und nicht leer sein. /// [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>("/api/v1/movies"))!.Total, Is.EqualTo(1)); } /// Ein falscher Schlüssel steht im Scanbericht, nicht nur im Protokoll. [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>("/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>("/api/v1/series"); Assert.That(serien!.Items[0].Overview, Is.EqualTo("Vier Familien.")); var folgen = await client.GetFromJsonAsync>( $"/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.")); } /// Folgen einer Serie ohne Zuordnung werden gar nicht erst gefragt. [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 { /// Der Schlüssel darf nie zurückkommen. /// /// /// Er gäbe jedem Angemeldeten das TMDB-Kontingent des Betreibers in die /// Hand. Zum Anzeigen genügt die Auskunft, dass einer da ist. /// [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(); 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("/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)); } /// Der Weg, der sonst die Datenbank herausgäbe. [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)); } }