Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -0,0 +1,50 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace Flaro.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; } = [];
|
||||
|
||||
/// <summary>Die Authorization-Kopfzeile je Aufruf — oder <c>null</c>.</summary>
|
||||
public List<string?> Kopfzeilen { 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);
|
||||
Kopfzeilen.Add(anfrage.Headers.Authorization?.ToString());
|
||||
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\Flaro.Metadata\Flaro.Metadata.csproj" />
|
||||
<ProjectReference Include="..\..\src\Flaro.Core\Flaro.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Flaro.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.Metadata.Tests;
|
||||
|
||||
public class ImageCacheTests
|
||||
{
|
||||
private string _wurzel = "";
|
||||
|
||||
[SetUp]
|
||||
public void Anlegen() => _wurzel = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "flaro-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/flaro.db</c> ein Weg, die Datenbank
|
||||
/// herunterzuladen — samt aller Token-Fingerabdrücke.
|
||||
/// </remarks>
|
||||
[TestCase("../../config/flaro.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,224 @@
|
||||
using System.Net;
|
||||
using Flaro.Core.Metadata;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.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);
|
||||
|
||||
// Die erste Anfrage ist die Suche; danach folgt der Nachschlag der
|
||||
// IMDb-Kennung für die NFO-Datei.
|
||||
var aufruf = attrappe.Aufrufe[0];
|
||||
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[0], 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Welcher Treffer gewinnt, wenn mehrere in Frage kommen.</summary>
|
||||
public class TrefferwahlTests
|
||||
{
|
||||
private static TmdbClient Client(Attrappe attrappe) =>
|
||||
new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) },
|
||||
FesteEinstellungen.MitSchluessel(), NullLogger<TmdbClient>.Instance);
|
||||
|
||||
/// <summary>Am echten Dienst gemessen, nicht vermutet.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Suche nach „Dark" lieferte „Dark Shadows" von 1966.</b> TMDB
|
||||
/// sortiert nach Beliebtheit, und ohne Jahresangabe führt das in die Irre.
|
||||
/// Ein Treffer mit genau diesem Namen ist gemeint, auch wenn er weiter
|
||||
/// unten steht.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_genau_passender_Name_sticht_den_beliebteren()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson("""
|
||||
{"results":[
|
||||
{"id":1,"name":"Dark Shadows","first_air_date":"1966-06-27"},
|
||||
{"id":2,"name":"Dark","first_air_date":"2017-12-01"}
|
||||
]}
|
||||
""");
|
||||
|
||||
var serie = await Client(attrappe).FindSeriesAsync("Dark", null);
|
||||
Assert.That(serie!.ProviderId, Is.EqualTo(2));
|
||||
Assert.That(serie.Year, Is.EqualTo(2017));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Gross_und_Kleinschreibung_spielt_dabei_keine_Rolle()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson("""
|
||||
{"results":[{"id":1,"title":"Irgendwas"},{"id":2,"title":"DER UNTERGANG"}]}
|
||||
""");
|
||||
|
||||
var film = await Client(attrappe).FindMovieAsync("Der Untergang", null);
|
||||
Assert.That(film!.ProviderId, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
/// <summary>Ohne genaue Entsprechung bleibt es beim beliebtesten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine eigene Ähnlichkeitsbewertung zu bauen hiesse, TMDBs Rangfolge
|
||||
/// nachzubauen — mit weniger Daten.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ohne_genaue_Entsprechung_gewinnt_der_erste()
|
||||
{
|
||||
var attrappe = Attrappe.MitJson("""
|
||||
{"results":[{"id":1,"title":"Der Untergang des Hauses Usher"},{"id":2,"title":"Untergang"}]}
|
||||
""");
|
||||
|
||||
var film = await Client(attrappe).FindMovieAsync("Der Untergang", null);
|
||||
Assert.That(film!.ProviderId, Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Flaro.Metadata.Tests;
|
||||
|
||||
/// <summary>Womit sich Flaro bei TMDB ausweist.</summary>
|
||||
public class ZugangTests
|
||||
{
|
||||
private const string LeseToken = "eyJhbGciOiJIUzI1NiJ9.beispiel.unterschrift";
|
||||
private const string AelterSchluessel = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
private static (TmdbClient Client, Attrappe Attrappe) Mit(string zugang)
|
||||
{
|
||||
var attrappe = Attrappe.MitJson("""{"results":[{"id":1,"title":"Egal"}]}""");
|
||||
return (new TmdbClient(
|
||||
new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) },
|
||||
new FesteEinstellungen(new TmdbSettings(zugang, "de-DE")),
|
||||
NullLogger<TmdbClient>.Instance), attrappe);
|
||||
}
|
||||
|
||||
/// <summary>Ein Lese-Token gehört in die Kopfzeile, nicht in die Adresse.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Adresse steht in jedem Zugriffsprotokoll.</b> In jedem Reverse
|
||||
/// Proxy, in jeder Fehlermeldung, die eine URL zitiert. Eine Kopfzeile tut
|
||||
/// das nicht — und bei einem Schlüssel, der in einem offenen Projekt
|
||||
/// mitgeliefert wird, zählt jede Stelle, an der er nicht auftaucht.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Der_Lese_Token_geht_als_Bearer_und_nicht_in_die_Adresse()
|
||||
{
|
||||
var (client, attrappe) = Mit(LeseToken);
|
||||
await client.FindMovieAsync("Egal", null);
|
||||
|
||||
// **Jede** Anfrage, nicht nur die erste: seit der IMDb-Nachschlag
|
||||
// dazukam, gehen zwei hinaus, und beide müssen es richtig machen.
|
||||
Assert.That(attrappe.Kopfzeilen, Is.All.EqualTo($"Bearer {LeseToken}"));
|
||||
Assert.That(attrappe.Aufrufe, Is.All.Not.Contains("api_key"));
|
||||
Assert.That(attrappe.Aufrufe, Is.All.Not.Contains(LeseToken));
|
||||
}
|
||||
|
||||
/// <summary>Der ältere Schlüssel kann nur in die Adresse — also tut er das.</summary>
|
||||
[Test]
|
||||
public async Task Der_aeltere_Schluessel_geht_weiterhin_als_Abfrageteil()
|
||||
{
|
||||
var (client, attrappe) = Mit(AelterSchluessel);
|
||||
await client.FindMovieAsync("Egal", null);
|
||||
|
||||
Assert.That(attrappe.Kopfzeilen, Is.All.Null);
|
||||
Assert.That(attrappe.Aufrufe, Is.All.Contains($"api_key={AelterSchluessel}"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Auch_bei_Folgen_wird_der_Bearer_mitgeschickt()
|
||||
{
|
||||
var (client, attrappe) = Mit(LeseToken);
|
||||
await client.FindEpisodeAsync(70523, 1, 1);
|
||||
|
||||
Assert.That(attrappe.Kopfzeilen, Is.All.StartsWith("Bearer "));
|
||||
Assert.That(attrappe.Aufrufe, Is.All.Not.Contains("api_key"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Welcher Schlüssel gilt, wenn mehrere in Frage kommen.</summary>
|
||||
public class SchluesselwahlTests
|
||||
{
|
||||
[Test]
|
||||
public void Ein_eigener_Schluessel_sticht_den_mitgelieferten()
|
||||
{
|
||||
var gewählt = TmdbSettings.Resolve("mein-eigener", "de-DE");
|
||||
|
||||
Assert.That(gewählt.ApiKey, Is.EqualTo("mein-eigener"));
|
||||
Assert.That(gewählt.UsingProjectKey, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_eigenen_greift_der_mitgelieferte()
|
||||
{
|
||||
var gewählt = TmdbSettings.Resolve(null, null);
|
||||
|
||||
if (TmdbSettings.ProjectApiKey.Length == 0)
|
||||
{
|
||||
Assert.That(gewählt.ApiKey, Is.Null, "Ohne mitgelieferten Schlüssel bleibt es dabei");
|
||||
Assert.That(gewählt.UsingProjectKey, Is.False);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.That(gewählt.ApiKey, Is.EqualTo(TmdbSettings.ProjectApiKey));
|
||||
Assert.That(gewählt.UsingProjectKey, Is.True);
|
||||
}
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Ein_leerer_eigener_Schluessel_zaehlt_als_keiner(string leer)
|
||||
{
|
||||
Assert.That(TmdbSettings.Resolve(leer, null).UsingProjectKey,
|
||||
Is.EqualTo(TmdbSettings.ProjectApiKey.Length > 0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Leerraum_um_den_Schluessel_wird_abgeschnitten()
|
||||
{
|
||||
Assert.That(TmdbSettings.Resolve(" meiner ", " de-DE ").ApiKey, Is.EqualTo("meiner"));
|
||||
Assert.That(TmdbSettings.Resolve("meiner", " de-DE ").Language, Is.EqualTo("de-DE"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_Sprachangabe_gilt_Englisch()
|
||||
{
|
||||
Assert.That(TmdbSettings.Resolve("meiner", null).Language, Is.EqualTo("en-US"));
|
||||
}
|
||||
|
||||
/// <summary>Der mitgelieferte Schlüssel darf nur lesen dürfen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Er steht öffentlich im Quellcode. Ein Lese-Token kann nichts ändern —
|
||||
/// ein älterer API-Schlüssel schon. Diese Prüfung hält fest, dass hier
|
||||
/// keiner der falschen Art hineingerät.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Der_mitgelieferte_Schluessel_ist_ein_Lese_Token()
|
||||
{
|
||||
if (TmdbSettings.ProjectApiKey.Length == 0) Assert.Pass("Kein Schlüssel mitgeliefert.");
|
||||
|
||||
Assert.That(TmdbSettings.ProjectApiKey, Does.StartWith("eyJ"),
|
||||
"Erwartet wird ein API Read Access Token, kein älterer API-Schlüssel");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user