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,36 @@
|
||||
using Flaro.Core.Metadata;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Ein Anbieter, der nichts weiss und nichts fragt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die Voreinstellung für alle Tests, die sich nicht um Metadaten kümmern.
|
||||
/// Ohne ihn liefe bei jedem Scan eine echte Anfrage hinaus — und ein Projekt,
|
||||
/// dessen Tests einen fremden Dienst brauchen, hat am Ende keine Tests.
|
||||
/// </remarks>
|
||||
public sealed class AbgeschalteterAnbieter : IMetadataProvider
|
||||
{
|
||||
public Task<bool> IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(false);
|
||||
|
||||
public Task<MovieMetadata?> FindMovieAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
|
||||
|
||||
public Task<SeriesMetadata?> FindSeriesAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
|
||||
|
||||
public Task<EpisodeMetadata?> FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
|
||||
|
||||
public Task<MovieMetadata?> GetMovieAsync(int id, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
|
||||
|
||||
public Task<SeriesMetadata?> GetSeriesAsync(int id, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
|
||||
|
||||
public Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
|
||||
|
||||
public Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
throw new InvalidOperationException("In diesem Test darf nichts nachgeschlagen werden.");
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Anmelden am Fernseher, ohne ein Passwort zu tippen.</summary>
|
||||
public class CodeSignInTests
|
||||
{
|
||||
private static Task<HttpResponseMessage> CodeAnfordern(FlaroServer server) =>
|
||||
server.Client.PostAsJsonAsync("/api/v1/auth/code", new CodeRequest("Wohnzimmer", "tv-1"));
|
||||
|
||||
[Test]
|
||||
public async Task Der_ganze_Weg_vom_Bildschirm_zum_Token()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
// 1. Der Fernseher fordert an und zeigt den Code.
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
Assert.That(angefordert!.Code, Does.Match("^[0-9]{6}$"));
|
||||
|
||||
// 2. Noch hat niemand bestätigt.
|
||||
var wartend = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
Assert.That(wartend!.State, Is.EqualTo("pending"));
|
||||
Assert.That(wartend.Login, Is.Null);
|
||||
|
||||
// 3. Am Telefon bestätigen.
|
||||
var bestätigt = await server.AlsAngemeldeter(telefon.Token)
|
||||
.PostAsync($"/api/v1/auth/code/{angefordert.Code}", null);
|
||||
bestätigt.EnsureSuccessStatusCode();
|
||||
|
||||
// 4. Der Fernseher holt sein Token ab.
|
||||
var fertig = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
Assert.That(fertig!.State, Is.EqualTo("approved"));
|
||||
Assert.That(fertig.Login!.Token, Is.Not.Empty);
|
||||
|
||||
// Und es gilt auch wirklich.
|
||||
var ich = await server.AlsAngemeldeter(fertig.Login.Token)
|
||||
.GetFromJsonAsync<AuthenticatedUser>("/api/v1/auth/me");
|
||||
Assert.That(ich!.UserName, Is.EqualTo("guido"));
|
||||
}
|
||||
|
||||
/// <summary>Der Code allein darf niemals ein Token herausgeben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die wichtigste Prüfung dieser Datei.</b> Gäbe der Server auf einen
|
||||
/// erratenen Abholschein hin Token heraus, wären sechsstellige Codes ein
|
||||
/// Türschloss mit einer Million Schlüsseln — in Minuten durchprobiert, und
|
||||
/// am Ende stünde ein fremdes Gerät in der Mediathek.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_fremder_Abholschein_bekommt_nichts()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var echt = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
await server.AlsAngemeldeter(telefon.Token).PostAsync($"/api/v1/auth/code/{echt!.Code}", null);
|
||||
|
||||
// Der Code ist bestätigt — aber der Abholschein stimmt nicht.
|
||||
var erraten = await server.Client.GetFromJsonAsync<CodeState>("/api/v1/auth/code/ausgedacht");
|
||||
|
||||
Assert.That(erraten!.State, Is.EqualTo("expired"));
|
||||
Assert.That(erraten.Login, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Auch der Code selbst taugt nicht als Abholschein.</summary>
|
||||
[Test]
|
||||
public async Task Der_Code_ist_kein_Abholschein()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var echt = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
await server.AlsAngemeldeter(telefon.Token).PostAsync($"/api/v1/auth/code/{echt!.Code}", null);
|
||||
|
||||
var mitCode = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{echt.Code}");
|
||||
Assert.That(mitCode!.Login, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_abgeholtes_Token_gibt_es_kein_zweites_Mal()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
await server.AlsAngemeldeter(telefon.Token).PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
|
||||
var erstesMal = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
var zweitesMal = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
|
||||
Assert.That(erstesMal!.State, Is.EqualTo("approved"));
|
||||
Assert.That(zweitesMal!.State, Is.EqualTo("expired"));
|
||||
Assert.That(zweitesMal.Login, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Bestätigen darf nur, wer angemeldet ist.</summary>
|
||||
[Test]
|
||||
public async Task Ein_Unangemeldeter_kann_nichts_bestaetigen()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
|
||||
var versuch = await server.Client.PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
|
||||
var danach = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
|
||||
Assert.That(danach!.State, Is.EqualTo("pending"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_erfundener_Code_laesst_sich_nicht_bestaetigen()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
|
||||
var versuch = await server.AlsAngemeldeter(telefon.Token).PostAsync("/api/v1/auth/code/000000", null);
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
/// <summary>Am Telefon soll stehen, was man da hereinlässt.</summary>
|
||||
[Test]
|
||||
public async Task Die_Bestaetigung_nennt_das_Geraet()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
|
||||
|
||||
var antwort = await server.AlsAngemeldeter(telefon.Token)
|
||||
.PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
|
||||
|
||||
Assert.That(await antwort.Content.ReadAsStringAsync(), Does.Contain("Wohnzimmer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ohne_Anmeldung_gibt_es_nichts()
|
||||
{
|
||||
Film("Geschuetzt (2020)", "mp4");
|
||||
var (_, datei) = await Eingelesen();
|
||||
|
||||
var antwort = await _server!.Client.GetAsync($"/api/v1/files/{datei.Id}/download");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<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.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<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.Api\Flaro.Api.csproj" />
|
||||
<ProjectReference Include="..\..\src\Flaro.Data\Flaro.Data.csproj" />
|
||||
<ProjectReference Include="..\..\src\Flaro.Core\Flaro.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Was beim allerersten Start passiert, bevor irgendetwas anderes greift.</summary>
|
||||
public class FlaroPathsTests
|
||||
{
|
||||
private static readonly string[] Variablen = ["FLARO_DATA", "FLARO_CACHE", "FLARO_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><Projektverzeichnis>/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()
|
||||
{
|
||||
_vorher = Variablen.ToDictionary(v => v, Environment.GetEnvironmentVariable);
|
||||
_wurzel = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "flaro-pfade-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
// Zurücksetzen, sonst lässt sich das gesperrte Verzeichnis nicht 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) { }
|
||||
|
||||
foreach (var (name, wert) in _vorher) Environment.SetEnvironmentVariable(name, wert);
|
||||
}
|
||||
|
||||
private FlaroPaths Pfade(string? daten = null) => new()
|
||||
{
|
||||
Data = daten ?? Path.Combine(_wurzel, "config"),
|
||||
Cache = Path.Combine(_wurzel, "cache"),
|
||||
Transcode = Path.Combine(_wurzel, "transcode"),
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void Fehlende_Verzeichnisse_werden_angelegt()
|
||||
{
|
||||
var pfade = Pfade();
|
||||
pfade.EnsureExists();
|
||||
|
||||
Assert.That(Directory.Exists(pfade.Data), Is.True);
|
||||
Assert.That(Directory.Exists(pfade.Cache), Is.True);
|
||||
Assert.That(Directory.Exists(pfade.Transcode), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ein_zweiter_Start_stoert_sich_nicht_am_ersten()
|
||||
{
|
||||
var pfade = Pfade();
|
||||
pfade.EnsureExists();
|
||||
Assert.DoesNotThrow(() => pfade.EnsureExists());
|
||||
}
|
||||
|
||||
/// <summary>Der häufigste Stolperstein beim ersten Containerstart.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Hier wird die Diagnose geprüft, nicht die Funktion.</b> Ein
|
||||
/// vorhandenes, aber gesperrtes Verzeichnis lässt
|
||||
/// <c>Directory.CreateDirectory</c> durchgehen; ohne eigene Schreibprobe
|
||||
/// stirbt der Server erst später an „unable to open database file" — einer
|
||||
/// Meldung, die weder das Verzeichnis nennt noch verrät, dass es um
|
||||
/// Dateirechte geht.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Ein_gesperrtes_Verzeichnis_faellt_beim_Start_auf_und_nennt_den_Grund()
|
||||
{
|
||||
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());
|
||||
|
||||
Assert.That(fehler!.Message, Does.Contain("FLARO_DATA"), "Welche Einstellung gemeint ist");
|
||||
Assert.That(fehler.Message, Does.Contain(gesperrt), "Welches Verzeichnis");
|
||||
Assert.That(fehler.Message, Does.Contain("uid="), "Als wer der Server läuft");
|
||||
Assert.That(fehler.Message, Does.Contain("chown"), "Was zu tun ist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_Umgebungsvariablen_liegt_alles_neben_dem_Programm()
|
||||
{
|
||||
foreach (var name in new[] { "FLARO_DATA", "FLARO_CACHE", "FLARO_TRANSCODE" })
|
||||
Environment.SetEnvironmentVariable(name, null);
|
||||
|
||||
var pfade = FlaroPaths.FromEnvironment(_wurzel);
|
||||
|
||||
Assert.That(pfade.Data, Is.EqualTo(Path.Combine(_wurzel, "data")));
|
||||
Assert.That(pfade.DatabaseFile, Does.EndWith("flaro.db"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Die_Umgebung_sticht_die_Voreinstellung()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FLARO_DATA", "/woanders");
|
||||
Assert.That(FlaroPaths.FromEnvironment(_wurzel).Data, Is.EqualTo("/woanders"));
|
||||
// Zurückgesetzt wird im TearDown, für alle drei Variablen gemeinsam.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Flaro.Core.Metadata;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Ein frisch gestarteter Server mit leerer Datenbank.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine echte SQLite-Datei, kein Ersatz im Arbeitsspeicher.</b> Damit läuft
|
||||
/// auch das durch, worauf es beim ersten Start ankommt: Verzeichnisse anlegen,
|
||||
/// Migration wandern lassen, Einrichtung anbieten. Genau diese Kette sieht ein
|
||||
/// Fremder als Erstes — und genau sie würde ein Ersatzspeicher überspringen.
|
||||
/// </remarks>
|
||||
public sealed class FlaroServer : IDisposable
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _grundlage;
|
||||
private readonly WebApplicationFactory<Program> _fabrik;
|
||||
private readonly string _verzeichnis;
|
||||
|
||||
public HttpClient Client { get; }
|
||||
|
||||
public FlaroServer(Action<IServiceCollection>? dienste = null)
|
||||
{
|
||||
_verzeichnis = Path.Combine(Path.GetTempPath(), "flaro-test-" + Guid.CreateVersion7().ToString("N"));
|
||||
Environment.SetEnvironmentVariable("FLARO_DATA", Path.Combine(_verzeichnis, "config"));
|
||||
Environment.SetEnvironmentVariable("FLARO_CACHE", Path.Combine(_verzeichnis, "cache"));
|
||||
Environment.SetEnvironmentVariable("FLARO_TRANSCODE", Path.Combine(_verzeichnis, "transcode"));
|
||||
// Die Zugriffsgrenze zählt je Adresse, und im Test kommt alles von
|
||||
// derselben. Ohne das Hochsetzen prüfte man hier die Grenze statt der
|
||||
// Anmeldung.
|
||||
Environment.SetEnvironmentVariable("FLARO_RATELIMIT_SIGNIN", "10000");
|
||||
|
||||
_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));
|
||||
|
||||
// **Keine Anfragen nach draussen, solange ein Test es nicht
|
||||
// ausdrücklich will.** Seit ein Schlüssel mitgeliefert wird, würde
|
||||
// jeder Scan sonst wirklich bei TMDB nachfragen: die Tests hingen
|
||||
// am WLAN, und ihre Erwartungen an fremden Daten. „Der Untergang"
|
||||
// hiess prompt „Downfall", weil die Voreinstellung Englisch ist.
|
||||
//
|
||||
// Vor `dienste`, damit ein Test seinen eigenen Anbieter
|
||||
// nachschieben kann — die spätere Eintragung gewinnt.
|
||||
sammlung.AddScoped<IMetadataProvider, AbgeschalteterAnbieter>();
|
||||
|
||||
dienste?.Invoke(sammlung);
|
||||
}));
|
||||
Client = _fabrik.CreateClient();
|
||||
}
|
||||
|
||||
/// <summary>Richtet ein und meldet den Administrator an.</summary>
|
||||
public async Task<LoginResponse> SetUpAndSignIn(string name = "guido", string passwort = "ein-langes-passwort")
|
||||
{
|
||||
var einrichten = await Client.PostAsJsonAsync("/api/v1/system/setup", new SetupRequest(name, passwort));
|
||||
einrichten.EnsureSuccessStatusCode();
|
||||
return await SignIn(name, passwort);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse> SignIn(string name, string passwort, string gerät = "iPhone", string kennung = "geraet-1")
|
||||
{
|
||||
var antwort = await Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest(name, passwort, gerät, kennung));
|
||||
antwort.EnsureSuccessStatusCode();
|
||||
return (await antwort.Content.ReadFromJsonAsync<LoginResponse>())!;
|
||||
}
|
||||
|
||||
/// <summary>Ein eigener Client, der ein Token vorzeigt.</summary>
|
||||
public HttpClient AlsAngemeldeter(string token)
|
||||
{
|
||||
var client = _fabrik.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
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,100 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
public class ManagementTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Ein_Administrator_legt_Benutzer_an()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var angelegt = await client.PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("mitbewohner", "auch-ein-langes-passwort", false));
|
||||
Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
|
||||
var alle = await client.GetFromJsonAsync<List<AuthenticatedUser>>("/api/v1/users");
|
||||
Assert.That(alle!.Select(u => u.UserName), Is.EquivalentTo(new[] { "guido", "mitbewohner" }));
|
||||
}
|
||||
|
||||
/// <summary>Wer kein Administrator ist, verwaltet auch nichts.</summary>
|
||||
[Test]
|
||||
public async Task Ein_gewoehnlicher_Benutzer_darf_keine_Benutzer_anlegen()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("mitbewohner", "auch-ein-langes-passwort", false));
|
||||
|
||||
var gast = await server.SignIn("mitbewohner", "auch-ein-langes-passwort", "iPad", "geraet-9");
|
||||
var versuch = await server.AlsAngemeldeter(gast.Token).PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("noch-einer", "noch-ein-langes-passwort", true));
|
||||
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Derselbe_Benutzername_zweimal_wird_abgelehnt()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var nochmal = await client.PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("guido", "wieder-ein-langes-passwort", false));
|
||||
|
||||
Assert.That(nochmal.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
}
|
||||
|
||||
/// <summary>Ein Tippfehler im Pfad fällt sofort auf, nicht erst beim Scan.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die häufigste Verwechslung beim Einrichten im Container: der Pfad auf
|
||||
/// dem Wirt statt der im Container. Ohne diese Prüfung bliebe die
|
||||
/// Bibliothek dauerhaft leer, und der Grund stünde allenfalls im Protokoll.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_Bibliothek_auf_einem_Pfad_den_es_nicht_gibt_wird_abgelehnt()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
|
||||
var versuch = await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Filme", "Movies", "/gibt/es/nicht"));
|
||||
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
Assert.That(await versuch.Content.ReadAsStringAsync(), Does.Contain("eingeh"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Bibliothek_auf_einem_vorhandenen_Pfad_wird_angelegt()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
var pfad = Path.GetTempPath();
|
||||
|
||||
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Filme", "movies", pfad));
|
||||
Assert.That(angelegt.StatusCode, Is.EqualTo(HttpStatusCode.Created));
|
||||
|
||||
var alle = await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries");
|
||||
Assert.That(alle!.Single().Kind, Is.EqualTo("Movies"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_unbekannte_Art_wird_abgelehnt()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
|
||||
var versuch = await server.AlsAngemeldeter(admin.Token).PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Musik", "Music", Path.GetTempPath()));
|
||||
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Media;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Eine falsche Zuordnung richtigstellen — und sie festschreiben.</summary>
|
||||
public class MatchCorrectionTests
|
||||
{
|
||||
private string _bestand = "";
|
||||
private string _filmOrdner = "";
|
||||
private FlaroServer? _server;
|
||||
|
||||
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-korrektur-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
_filmOrdner = Directory.CreateDirectory(Path.Combine(_bestand, "Dark (2017)")).FullName;
|
||||
|
||||
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",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", Path.Combine(_filmOrdner, "Dark (2017).mkv"),
|
||||
}) start.ArgumentList.Add(a);
|
||||
using var p = Process.Start(start)!;
|
||||
p.StandardError.ReadToEnd();
|
||||
p.WaitForExit();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
_server?.Dispose(); _server = null;
|
||||
try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>Ein Anbieter, der zwei verschiedene Filme kennt.</summary>
|
||||
private sealed class ZweiTreffer : IMetadataProvider
|
||||
{
|
||||
public static readonly MovieMetadata Falsch =
|
||||
new(1, "Dark Shadows", 1966, "Eine Fernsehserie von 1966.", null, null, "tt0059978");
|
||||
public static readonly MovieMetadata Richtig =
|
||||
new(70523, "Dark", 2017, "Nach dem Verschwinden eines Kindes.", null, null, "tt5753856");
|
||||
|
||||
/// <summary>Ein Treffer mit <b>anderem Jahr</b> als im Dateinamen.</summary>
|
||||
public static readonly MovieMetadata AnderesJahr =
|
||||
new(613, "Der Untergang", 2004, "Berlin, April 1945.", null, null, "tt0363163");
|
||||
|
||||
public Task<bool> IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
// Die Suche liefert den beliebteren zuerst — genau wie TMDB.
|
||||
public Task<MovieMetadata?> FindMovieAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
Task.FromResult<MovieMetadata?>(Falsch);
|
||||
|
||||
public Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<MovieMetadata>>([Falsch, Richtig]);
|
||||
|
||||
public Task<MovieMetadata?> GetMovieAsync(int id, CancellationToken ct = default) =>
|
||||
Task.FromResult<MovieMetadata?>(
|
||||
id == Richtig.ProviderId ? Richtig
|
||||
: id == Falsch.ProviderId ? Falsch
|
||||
: id == AnderesJahr.ProviderId ? AnderesJahr
|
||||
: null);
|
||||
|
||||
public Task<SeriesMetadata?> FindSeriesAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
Task.FromResult<SeriesMetadata?>(null);
|
||||
public Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<SeriesMetadata>>([]);
|
||||
public Task<SeriesMetadata?> GetSeriesAsync(int id, CancellationToken ct = default) =>
|
||||
Task.FromResult<SeriesMetadata?>(null);
|
||||
public Task<EpisodeMetadata?> FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) =>
|
||||
Task.FromResult<EpisodeMetadata?>(null);
|
||||
}
|
||||
|
||||
private async Task<(HttpClient Client, MovieResponse Film)> Eingelesen(bool beiwerkSchreiben)
|
||||
{
|
||||
_server = new FlaroServer(dienste =>
|
||||
{
|
||||
dienste.AddScoped<IMetadataProvider, ZweiTreffer>();
|
||||
dienste.AddSingleton<ISidecarStore>(
|
||||
new SidecarWriter(new SidecarOptions(beiwerkSchreiben),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<SidecarWriter>.Instance));
|
||||
});
|
||||
|
||||
var admin = await _server.SetUpAndSignIn();
|
||||
var client = _server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
||||
new CreateLibraryRequest("Filme", "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");
|
||||
return (client, filme!.Items.Single());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Der_Scan_ordnet_zuerst_falsch_zu()
|
||||
{
|
||||
var (_, film) = await Eingelesen(beiwerkSchreiben: false);
|
||||
Assert.That(film.Title, Is.EqualTo("Dark Shadows"), "So sieht der Fehler aus, den es zu beheben gilt");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Die_Vorschlagsliste_enthaelt_den_richtigen()
|
||||
{
|
||||
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
||||
|
||||
var vorschläge = await client.GetFromJsonAsync<List<MatchCandidate>>($"/api/v1/movies/{film.Id}/matches");
|
||||
|
||||
Assert.That(vorschläge, Has.Count.EqualTo(2));
|
||||
Assert.That(vorschläge!.Select(v => v.Title), Does.Contain("Dark"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Korrektur_greift_sofort()
|
||||
{
|
||||
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
||||
|
||||
var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523));
|
||||
antwort.EnsureSuccessStatusCode();
|
||||
|
||||
var stand = await antwort.Content.ReadFromJsonAsync<MatchState>();
|
||||
Assert.That(stand!.Title, Is.EqualTo("Dark"));
|
||||
Assert.That(stand.TmdbId, Is.EqualTo(70523));
|
||||
Assert.That(stand.ImdbId, Is.EqualTo("tt5753856"));
|
||||
Assert.That(stand.Locked, Is.True);
|
||||
|
||||
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
Assert.That(danach!.Items.Single().Title, Is.EqualTo("Dark"));
|
||||
}
|
||||
|
||||
/// <summary>Die Korrektur überlebt einen Neuaufbau der Datenbank.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Darum geht es bei der NFO-Datei.</b> In der Datenbank allein wäre
|
||||
/// die Entscheidung mit dem nächsten Umzug oder Neuaufbau verloren.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_Korrektur_landet_in_der_NFO_Datei()
|
||||
{
|
||||
var (client, film) = await Eingelesen(beiwerkSchreiben: true);
|
||||
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
var nfo = Path.Combine(_filmOrdner, "movie.nfo");
|
||||
Assert.That(File.Exists(nfo), Is.True, "Die NFO muss neben dem Film liegen");
|
||||
|
||||
var inhalt = await File.ReadAllTextAsync(nfo);
|
||||
var kennungen = Nfo.ReadIds(inhalt);
|
||||
Assert.That(kennungen.Tmdb, Is.EqualTo(70523));
|
||||
Assert.That(kennungen.Imdb, Is.EqualTo("tt5753856"));
|
||||
Assert.That(Nfo.ReadLocked(inhalt), Is.True, "Damit kein Lauf die Entscheidung überstimmt");
|
||||
Assert.That(inhalt, Does.Contain("<title>Dark</title>"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ohne_Schreiberlaubnis_bleibt_der_Ordner_unberuehrt()
|
||||
{
|
||||
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
||||
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
Assert.That(Directory.GetFiles(_filmOrdner).Select(Path.GetFileName),
|
||||
Is.EqualTo(new[] { "Dark (2017).mkv" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Kennung_die_es_nicht_gibt_wird_abgelehnt()
|
||||
{
|
||||
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
||||
|
||||
var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(999999));
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
/// <summary>Eine Korrektur muss den nächsten Scan überstehen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Live gefunden, und der Fehler war unangenehm.</b> Nach der Korrektur
|
||||
/// stand im Ordner „Dark (2017)", in der Datenbank aber 2004. Der nächste
|
||||
/// Scan suchte nach (Dark, 2017), fand nichts, legte den Film neu an und
|
||||
/// löschte den korrigierten als verwaist — die Zuordnung überlebte nur,
|
||||
/// weil sie in der NFO stand. Zugeordnet wird deshalb über
|
||||
/// <c>ParsedYear</c>, das sich nie ändert.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_Korrektur_ueberlebt_den_naechsten_Scan()
|
||||
{
|
||||
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
||||
|
||||
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(613)))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
var bibliothek = (await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries"))!.Single();
|
||||
(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 danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
Assert.That(danach!.Total, Is.EqualTo(1), "Kein zweiter Eintrag für denselben Film");
|
||||
|
||||
var geblieben = danach.Items.Single();
|
||||
Assert.That(geblieben.Id, Is.EqualTo(film.Id), "Derselbe Eintrag, nicht ein neuer");
|
||||
Assert.That(geblieben.Title, Is.EqualTo("Der Untergang"));
|
||||
Assert.That(geblieben.Year, Is.EqualTo(2004), "Und nicht wieder 2017 aus dem Dateinamen");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Nur_Administratoren_duerfen_korrigieren()
|
||||
{
|
||||
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
||||
await client.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)
|
||||
.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523));
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
using Flaro.Core.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Flaro.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);
|
||||
}
|
||||
|
||||
public Task<MovieMetadata?> GetMovieAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
Gefragt.Add($"filmId:{id}");
|
||||
if (Wirft is not null) throw Wirft;
|
||||
return Task.FromResult(Film);
|
||||
}
|
||||
|
||||
public Task<SeriesMetadata?> GetSeriesAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
Gefragt.Add($"serieId:{id}");
|
||||
if (Wirft is not null) throw Wirft;
|
||||
return Task.FromResult(Serie);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string t, int? j, CancellationToken ct = default)
|
||||
{
|
||||
Gefragt.Add($"suchFilm:{t}");
|
||||
return Task.FromResult<IReadOnlyList<MovieMetadata>>(Film is null ? [] : [Film]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string t, int? j, CancellationToken ct = default)
|
||||
{
|
||||
Gefragt.Add($"suchSerie:{t}");
|
||||
return Task.FromResult<IReadOnlyList<SeriesMetadata>>(Serie is null ? [] : [Serie]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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(), "flaro-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 FlaroServer 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(FlaroServer 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 FlaroServer();
|
||||
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"));
|
||||
}
|
||||
|
||||
/// <summary>Ohne eigenen Schlüssel greift der mitgelieferte.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <c>UsingProjectKey</c> ist der Unterschied, den die Oberfläche zeigen
|
||||
/// muss: „es läuft, aber über das gemeinsame Kontingent" ist etwas anderes
|
||||
/// als „du hast einen eigenen hinterlegt".
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ohne_eigenen_Schluessel_greift_der_mitgelieferte()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var stand = await server.AlsAngemeldeter(admin.Token)
|
||||
.GetFromJsonAsync<MetadataSettingsResponse>("/api/v1/settings/metadata");
|
||||
|
||||
var mitgeliefert = Flaro.Metadata.TmdbSettings.ProjectApiKey.Length > 0;
|
||||
Assert.That(stand!.HasApiKey, Is.EqualTo(mitgeliefert));
|
||||
Assert.That(stand.UsingProjectKey, Is.EqualTo(mitgeliefert));
|
||||
Assert.That(stand.Language, Is.EqualTo("en-US"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_eigener_Schluessel_loest_den_mitgelieferten_ab()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var admin = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(admin.Token);
|
||||
|
||||
await client.PutAsJsonAsync("/api/v1/settings/metadata",
|
||||
new MetadataSettingsRequest("mein-eigener-schluessel", "de-DE"));
|
||||
|
||||
var stand = await client.GetFromJsonAsync<MetadataSettingsResponse>("/api/v1/settings/metadata");
|
||||
Assert.That(stand!.HasApiKey, Is.True);
|
||||
Assert.That(stand.UsingProjectKey, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>TMDB verlangt den Hinweis — und zwar ohne Anmeldung abrufbar.</summary>
|
||||
[Test]
|
||||
public async Task Der_Pflichthinweis_zu_TMDB_steht_in_der_Systemauskunft()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
||||
|
||||
var tmdb = info!.Attributions.Single();
|
||||
Assert.That(tmdb.Text, Does.Contain("TMDB").And.Contain("not endorsed"));
|
||||
Assert.That(tmdb.LogoUrl, Does.StartWith("https://"));
|
||||
}
|
||||
|
||||
[TestCase("deutsch")]
|
||||
[TestCase("de_DE")]
|
||||
[TestCase("DE")]
|
||||
[TestCase("de-de")]
|
||||
public async Task Eine_unsinnige_Sprachkennung_wird_abgelehnt(string sprache)
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
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 FlaroServer();
|
||||
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 FlaroServer();
|
||||
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%2Fflaro.db")]
|
||||
[TestCase("nichtsechzigvierzeichen")]
|
||||
[TestCase("ZZZZ567890123456789012345678901234567890123456789012345678901234")]
|
||||
public async Task Ein_Schluessel_der_keiner_ist_fuehrt_ins_Leere(string versuch)
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Scan gegen einen echten Ordner mit echten Dateien.</summary>
|
||||
public class ScanTests
|
||||
{
|
||||
private string _bestand = "";
|
||||
|
||||
[SetUp]
|
||||
public void Anlegen() => _bestand = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
private void Datei(string relativ, int bytes = 2048)
|
||||
{
|
||||
var voll = Path.Combine(_bestand, relativ);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
|
||||
File.WriteAllBytes(voll, new byte[bytes]);
|
||||
}
|
||||
|
||||
private async Task<(HttpClient Admin, Guid LibraryId)> MitBibliothek(FlaroServer 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();
|
||||
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
||||
return (client, bibliothek.Id);
|
||||
}
|
||||
|
||||
/// <summary>Stösst an und wartet, bis der Hintergrundlauf fertig ist.</summary>
|
||||
private static async Task<ScanStatus> Scannen(HttpClient client, Guid bibliothek)
|
||||
{
|
||||
var angestossen = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null);
|
||||
Assert.That(angestossen.StatusCode, Is.EqualTo(HttpStatusCode.Accepted));
|
||||
|
||||
for (var versuch = 0; versuch < 100; versuch++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
|
||||
if (stand!.State is "finished") return stand;
|
||||
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
|
||||
await Task.Delay(50);
|
||||
}
|
||||
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
|
||||
return null!;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Filme_werden_gefunden_und_benannt()
|
||||
{
|
||||
Datei("The.Matrix.1999.1080p.BluRay.x264-GRP.mkv");
|
||||
Datei("Der Untergang (2004).mkv");
|
||||
Datei("The Matrix (1999).srt"); // zählt nicht
|
||||
Datei("@eaDir/The.Matrix.1999.mkv"); // zählt auch nicht
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||||
|
||||
var stand = await Scannen(client, bibliothek);
|
||||
Assert.That(stand.FilesFound, Is.EqualTo(2));
|
||||
Assert.That(stand.Added, Is.EqualTo(2));
|
||||
|
||||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
Assert.That(filme!.Total, Is.EqualTo(2));
|
||||
// Sortiert ohne Artikel: „Matrix" vor „Untergang".
|
||||
Assert.That(filme.Items.Select(m => m.Title),
|
||||
Is.EqualTo(new[] { "The Matrix", "Der Untergang" }));
|
||||
Assert.That(filme.Items[0].Year, Is.EqualTo(1999));
|
||||
}
|
||||
|
||||
/// <summary>Zweimal scannen muss dasselbe ergeben wie einmal.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Sonst wächst die Mediathek bei jedem Lauf.</b> Das ist der Fehler,
|
||||
/// den man erst nach Wochen bemerkt — wenn jeder Film fünfmal dasteht.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_zweiter_Lauf_aendert_nichts()
|
||||
{
|
||||
Datei("The Matrix (1999).mkv");
|
||||
Datei("Inception (2010).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||||
|
||||
await Scannen(client, bibliothek);
|
||||
var zweiter = await Scannen(client, bibliothek);
|
||||
|
||||
Assert.That(zweiter.Added, Is.EqualTo(0));
|
||||
Assert.That(zweiter.Removed, Is.EqualTo(0));
|
||||
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_geloeschte_Datei_verschwindet_aus_der_Mediathek()
|
||||
{
|
||||
Datei("The Matrix (1999).mkv");
|
||||
Datei("Inception (2010).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
File.Delete(Path.Combine(_bestand, "Inception (2010).mkv"));
|
||||
var danach = await Scannen(client, bibliothek);
|
||||
|
||||
Assert.That(danach.Removed, Is.EqualTo(1));
|
||||
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
||||
Assert.That(filme!.Items.Select(m => m.Title), Is.EqualTo(new[] { "The Matrix" }));
|
||||
}
|
||||
|
||||
/// <summary>4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme.</summary>
|
||||
[Test]
|
||||
public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag()
|
||||
{
|
||||
Datei("The Matrix (1999) - 1080p.mkv");
|
||||
Datei("The Matrix (1999) - 2160p.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(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].Files, Has.Count.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Serien_werden_in_Staffeln_und_Folgen_zerlegt()
|
||||
{
|
||||
Datei("Dark/Staffel 1/Dark - S01E01 - Geheimnisse.mkv");
|
||||
Datei("Dark/Staffel 1/Dark - S01E02 - Lügen.mkv");
|
||||
Datei("Dark/Staffel 2/Dark - S02E01 - Anfänge.mkv");
|
||||
Datei("Notruf.Hafenkante.S01E01.German.1080p.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||
Assert.That(serien!.Total, Is.EqualTo(2));
|
||||
|
||||
var dark = serien.Items.Single(s => s.Title == "Dark");
|
||||
Assert.That(dark.EpisodeCount, Is.EqualTo(3));
|
||||
Assert.That(dark.Seasons, Is.EqualTo(new[] { 1, 2 }));
|
||||
|
||||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>($"/api/v1/series/{dark.Id}/episodes");
|
||||
Assert.That(folgen!.Items.Select(f => f.Title),
|
||||
Is.EqualTo(new[] { "Geheimnisse", "Lügen", "Anfänge" }));
|
||||
|
||||
var nurStaffel2 = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/api/v1/series/{dark.Id}/episodes?season=2");
|
||||
Assert.That(nurStaffel2!.Items, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>Was keine Folgennummer trägt, wird nicht geraten.</summary>
|
||||
[Test]
|
||||
public async Task Dateien_ohne_Folgennummer_werden_gezaehlt_aber_nicht_zugeordnet()
|
||||
{
|
||||
Datei("Dark/Dark - S01E01.mkv");
|
||||
Datei("Dark/Irgendein Mitschnitt.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||||
var stand = await Scannen(client, bibliothek);
|
||||
|
||||
Assert.That(stand.FilesFound, Is.EqualTo(2));
|
||||
Assert.That(stand.Unrecognised, Is.EqualTo(1));
|
||||
Assert.That(stand.Added, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Die_Suche_grenzt_ein()
|
||||
{
|
||||
Datei("The Matrix (1999).mkv");
|
||||
Datei("The Matrix Reloaded (2003).mkv");
|
||||
Datei("Inception (2010).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var treffer = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?search=matrix");
|
||||
Assert.That(treffer!.Total, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
/// <summary>Eine Anfrage darf sich die Seitengrösse nicht selbst aussuchen.</summary>
|
||||
[Test]
|
||||
public async Task Die_Seitengroesse_ist_gedeckelt()
|
||||
{
|
||||
for (var i = 1; i <= 5; i++) Datei($"Film {i} (200{i}).mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?take=100000");
|
||||
Assert.That(seite!.Total, Is.EqualTo(5), "Die Gesamtzahl bleibt richtig");
|
||||
Assert.That(seite.Items, Has.Count.LessThanOrEqualTo(200));
|
||||
|
||||
var zweiteSeite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?skip=2&take=2");
|
||||
Assert.That(zweiteSeite!.Items, Has.Count.EqualTo(2));
|
||||
Assert.That(zweiteSeite.Total, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Vor_dem_ersten_Lauf_steht_der_Scan_auf_untaetig()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Movies");
|
||||
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
|
||||
Assert.That(stand!.State, Is.EqualTo("idle"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_gewoehnlicher_Benutzer_stoesst_keinen_Scan_an()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var (admin, bibliothek) = await MitBibliothek(server, "Movies");
|
||||
await admin.PostAsJsonAsync("/api/v1/users",
|
||||
new CreateUserRequest("gast", "auch-ein-langes-passwort", false));
|
||||
var gast = await server.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-9");
|
||||
|
||||
var versuch = await server.AlsAngemeldeter(gast.Token)
|
||||
.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null);
|
||||
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Eine_Bibliothek_die_es_nicht_gibt_laesst_sich_nicht_scannen()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var (client, _) = await MitBibliothek(server, "Movies");
|
||||
|
||||
var versuch = await client.PostAsync($"/api/v1/libraries/{Guid.CreateVersion7()}/scan", null);
|
||||
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
public class SetupTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Ein_frischer_Server_verlangt_Einrichtung()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
||||
Assert.That(info!.NeedsSetup, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Die_Einrichtung_legt_einen_Administrator_an()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var antwort = await server.Client.PostAsJsonAsync("/api/v1/system/setup",
|
||||
new SetupRequest("guido", "ein-langes-passwort"));
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
var benutzer = await antwort.Content.ReadFromJsonAsync<AuthenticatedUser>();
|
||||
Assert.That(benutzer!.UserName, Is.EqualTo("guido"));
|
||||
Assert.That(benutzer.IsAdministrator, Is.True);
|
||||
|
||||
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
||||
Assert.That(info!.NeedsSetup, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>Sonst stünde ein laufender Server dem ersten Fremden offen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Der Endpunkt muss ohne Anmeldung erreichbar sein — beim ersten Start
|
||||
/// gibt es ja noch niemanden, der sich anmelden könnte. Genau deshalb muss
|
||||
/// er sich danach selbst verschliessen.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_zweites_Mal_Einrichten_wird_abgelehnt()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var zweiterVersuch = await server.Client.PostAsJsonAsync("/api/v1/system/setup",
|
||||
new SetupRequest("eindringling", "auch-ein-langes-passwort"));
|
||||
|
||||
Assert.That(zweiterVersuch.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_zu_kurzes_Passwort_wird_abgelehnt()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var antwort = await server.Client.PostAsJsonAsync("/api/v1/system/setup",
|
||||
new SetupRequest("guido", "kurz"));
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||
var info = await server.Client.GetFromJsonAsync<SystemInfo>("/api/v1/system/info");
|
||||
Assert.That(info!.NeedsSetup, Is.True, "Ein abgelehnter Versuch darf nichts angelegt haben");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Die_Gesundheitsfrage_braucht_keine_Anmeldung()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var antwort = await server.Client.GetAsync("/health");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
public class SignInTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Mit_richtigem_Passwort_gibt_es_ein_Token()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var anmeldung = await server.SetUpAndSignIn();
|
||||
|
||||
Assert.That(anmeldung.Token, Is.Not.Empty);
|
||||
Assert.That(anmeldung.User.UserName, Is.EqualTo("guido"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mit_falschem_Passwort_gibt_es_keines()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("guido", "falsch-aber-lang", "iPhone", "geraet-1"));
|
||||
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Unbekannter Name und falsches Passwort sind nicht zu unterscheiden.</summary>
|
||||
[Test]
|
||||
public async Task Ein_unbekannter_Name_antwortet_wie_ein_falsches_Passwort()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var unbekannt = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("gibtesnicht", "irgendein-passwort", "iPhone", "geraet-1"));
|
||||
var falsch = await server.Client.PostAsJsonAsync("/api/v1/auth/login",
|
||||
new LoginRequest("guido", "irgendein-passwort", "iPhone", "geraet-1"));
|
||||
|
||||
Assert.That(unbekannt.StatusCode, Is.EqualTo(falsch.StatusCode));
|
||||
|
||||
// Nur die Begründung vergleichen, nicht den ganzen Körper: darin steht
|
||||
// je Anfrage eine neue Ablaufkennung, die nichts über das Konto sagt.
|
||||
static async Task<string?> Begründung(HttpResponseMessage antwort) =>
|
||||
(await antwort.Content.ReadFromJsonAsync<System.Text.Json.JsonDocument>())!
|
||||
.RootElement.GetProperty("detail").GetString();
|
||||
|
||||
Assert.That(await Begründung(unbekannt), Is.EqualTo(await Begründung(falsch)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mit_Token_beantwortet_der_Server_wer_man_ist()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var anmeldung = await server.SetUpAndSignIn();
|
||||
|
||||
var ich = await server.AlsAngemeldeter(anmeldung.Token)
|
||||
.GetFromJsonAsync<AuthenticatedUser>("/api/v1/auth/me");
|
||||
|
||||
Assert.That(ich!.Id, Is.EqualTo(anmeldung.User.Id));
|
||||
Assert.That(ich.IsAdministrator, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ohne_Token_antwortet_er_nicht()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.Client.GetAsync("/api/v1/auth/me");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Ein_erfundenes_Token_gilt_nicht()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
await server.SetUpAndSignIn();
|
||||
|
||||
var antwort = await server.AlsAngemeldeter("ausgedacht-aber-lang-genug").GetAsync("/api/v1/auth/me");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Nach dem Abmelden gilt das Token sofort nicht mehr.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Der Grund, warum hier keine JWT stehen: ein signiertes Token gälte bis
|
||||
/// zum Ablauf weiter, und für ein verlorenes Telefon ist das der falsche
|
||||
/// Satz.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Abmelden_entwertet_das_Token_augenblicklich()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var anmeldung = await server.SetUpAndSignIn();
|
||||
var client = server.AlsAngemeldeter(anmeldung.Token);
|
||||
|
||||
(await client.PostAsync("/api/v1/auth/logout", null)).EnsureSuccessStatusCode();
|
||||
|
||||
var danach = await client.GetAsync("/api/v1/auth/me");
|
||||
Assert.That(danach.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
/// <summary>Dasselbe Gerät ersetzt seinen Eintrag, statt einen zweiten anzulegen.</summary>
|
||||
[Test]
|
||||
public async Task Eine_erneute_Anmeldung_desselben_Geraets_entwertet_das_alte_Token()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var erste = await server.SetUpAndSignIn();
|
||||
var zweite = await server.SignIn("guido", "ein-langes-passwort", "iPhone", "geraet-1");
|
||||
|
||||
Assert.That(zweite.Token, Is.Not.EqualTo(erste.Token));
|
||||
var mitAltem = await server.AlsAngemeldeter(erste.Token).GetAsync("/api/v1/auth/me");
|
||||
Assert.That(mitAltem.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
var mitNeuem = await server.AlsAngemeldeter(zweite.Token).GetAsync("/api/v1/auth/me");
|
||||
Assert.That(mitNeuem.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
/// <summary>Ein zweites Gerät meldet das erste nicht ab.</summary>
|
||||
[Test]
|
||||
public async Task Ein_anderes_Geraet_laesst_das_erste_in_Ruhe()
|
||||
{
|
||||
using var server = new FlaroServer();
|
||||
var telefon = await server.SetUpAndSignIn();
|
||||
var fernseher = await server.SignIn("guido", "ein-langes-passwort", "Apple TV", "geraet-2");
|
||||
|
||||
foreach (var token in new[] { telefon.Token, fernseher.Token })
|
||||
{
|
||||
var antwort = await server.AlsAngemeldeter(token).GetAsync("/api/v1/auth/me");
|
||||
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user