using Microsoft.Extensions.Options;
using TeleWave.Application.Tests.TestSupport;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Metadata;
using Xunit;
namespace TeleWave.Application.Tests.Metadata;
///
/// Разбор ответов TMDb. Наружу тесты не ходят — HTTP подменён, проверяем и собранный адрес
/// (сериалы и фильмы живут в разных пространствах API), и то, что из ответа вычитано.
///
public class TmdbMetadataProviderTests
{
private static TmdbMetadataProvider Provider(FakeHttpClientFactory http) =>
new(
http,
Options.Create(
new MetadataOptions
{
Language = "ru-RU",
Tmdb = new TmdbOptions { ApiKey = "key" },
}
)
);
[Fact]
public async Task SearchShows_Series_ReadsNameAndFirstAirDate()
{
var http = FakeHttpClientFactory.Json(
"""
{"results":[
{"id":42,"name":"Сериал","first_air_date":"2001-05-06","overview":"о чём-то","poster_path":"/p.jpg"},
{"name":"без идентификатора"}
]}
"""
);
var found = await Provider(http)
.SearchShowsAsync("сериал", ShowKind.Series, CancellationToken.None);
Assert.Contains("/search/tv", http.LastUrl);
Assert.Contains("language=ru-RU", http.LastUrl);
// Запись без id пропущена: обратиться по ней всё равно не получится.
var candidate = Assert.Single(found);
Assert.Equal("42", candidate.ExternalId);
Assert.Equal("Сериал", candidate.Title);
Assert.Equal(2001, candidate.Year);
Assert.Equal("о чём-то", candidate.Overview);
Assert.Equal("https://image.tmdb.org/t/p/w500/p.jpg", candidate.PosterUrl);
Assert.Equal(ShowKind.Series, candidate.Kind);
}
[Fact]
public async Task SearchShows_Single_UsesMovieNamespace()
{
var http = FakeHttpClientFactory.Json(
"""{"results":[{"id":7,"title":"Фильм","release_date":"1999-03-31"}]}"""
);
var found = await Provider(http)
.SearchShowsAsync("фильм", ShowKind.Single, CancellationToken.None);
Assert.Contains("/search/movie", http.LastUrl);
var candidate = Assert.Single(found);
Assert.Equal("Фильм", candidate.Title);
Assert.Equal(1999, candidate.Year);
Assert.Null(candidate.PosterUrl);
Assert.Equal(ShowKind.Single, candidate.Kind);
}
[Fact]
public async Task SearchShows_ReturnsEmpty_WhenNoResultsSection()
{
var found = await Provider(FakeHttpClientFactory.Json("{}"))
.SearchShowsAsync("ничего", ShowKind.Series, CancellationToken.None);
Assert.Empty(found);
}
[Fact]
public async Task GetShow_Series_ReadsGenresAndContentRating()
{
var http = FakeHttpClientFactory.Json(
"""
{"name":"Сериал","first_air_date":"2001-05-06","overview":"описание","poster_path":"/p.jpg",
"genres":[{"id":28,"name":"Боевик"},{"name":"Драма"},{"id":99}],
"content_ratings":{"results":[{"iso_3166_1":"RU","rating":"16+"},{"iso_3166_1":"US","rating":"TV-14"}]}}
"""
);
var show = await Provider(http).GetShowAsync("42", ShowKind.Series, CancellationToken.None);
Assert.Contains("/tv/42", http.LastUrl);
Assert.Contains("append_to_response=content_ratings", http.LastUrl);
Assert.NotNull(show);
Assert.Equal("Сериал", show.Title);
Assert.Equal(2001, show.Year);
// Идентификатор впереди названия, безымянный жанр — только идентификатором.
Assert.Equal(["tmdb:28", "Боевик", "Драма", "tmdb:99"], show.Genres);
// US приоритетнее RU, хотя в ответе идёт вторым.
Assert.Equal("TV-14", show.ContentRating);
}
[Fact]
public async Task GetShow_Movie_PrefersTheatricalCertification()
{
// Постер в ответе есть — значит, добора язык-нейтрального не будет и запрос ровно один.
var http = FakeHttpClientFactory.Json(
"""
{"title":"Фильм","poster_path":"/p.jpg","release_dates":{"results":[{"iso_3166_1":"US","release_dates":[
{"type":4,"certification":"R"},
{"type":3,"certification":"PG-13"}
]}]}}
"""
);
var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None);
Assert.Contains("/movie/7", http.LastUrl);
Assert.Contains("append_to_response=release_dates", http.LastUrl);
Assert.Equal("PG-13", show?.ContentRating);
}
///
/// TMDb отдаёт постер под запрошенный язык, и у шоу без локализованной обложки поле пустое —
/// хотя в поиске картинка была. Без добора «Применить» молча оставляло бы шоу без постера.
///
[Fact]
public async Task GetShow_WithoutLocalizedPoster_FallsBackToNeutralOne()
{
// Локализованный ответ (с language) — без постера, язык-нейтральный — с ним.
var http = new FakeHttpClientFactory(url =>
FakeHttpClientFactory.Ok(
url.Contains("language=", StringComparison.Ordinal)
? """{"name":"Сериал"}"""
: """{"name":"Сериал","poster_path":"/neutral.jpg"}"""
)
);
var show = await Provider(http).GetShowAsync("9", ShowKind.Series, CancellationToken.None);
Assert.Equal(2, http.Urls.Count);
Assert.DoesNotContain("language=", http.LastUrl);
Assert.Contains("/tv/9", http.LastUrl);
Assert.EndsWith("/neutral.jpg", show?.PosterUrl);
}
/// Кадр серии — та же история, что и с постером шоу: под русский язык его часто нет.
[Fact]
public async Task GetEpisode_WithoutLocalizedStill_FallsBackToNeutralOne()
{
var http = new FakeHttpClientFactory(url =>
FakeHttpClientFactory.Ok(
url.Contains("language=", StringComparison.Ordinal)
? """{"name":"Серия"}"""
: """{"name":"Серия","still_path":"/neutral.jpg"}"""
)
);
var episode = await Provider(http).GetEpisodeAsync("9", 2, 5, CancellationToken.None);
Assert.Equal(2, http.Urls.Count);
Assert.DoesNotContain("language=", http.LastUrl);
Assert.Contains("/tv/9/season/2/episode/5", http.LastUrl);
Assert.EndsWith("/neutral.jpg", episode?.StillUrl);
}
[Fact]
public async Task GetShow_Movie_FallsBackToAnyReleaseCertification()
{
var http = FakeHttpClientFactory.Json(
"""
{"title":"Фильм","release_dates":{"results":[{"iso_3166_1":"US","release_dates":[
{"type":5,"certification":"NC-17"}
]}]}}
"""
);
var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None);
Assert.Equal("NC-17", show?.ContentRating);
}
[Fact]
public async Task GetShow_Movie_SkipsCountriesWithoutCertification()
{
var http = FakeHttpClientFactory.Json(
"""
{"title":"Фильм","release_dates":{"results":[
{"iso_3166_1":"US"},
{"iso_3166_1":"US","release_dates":[{"type":3,"certification":" "}]},
{"iso_3166_1":"RU","release_dates":[{"type":3,"certification":"18+"}]}
]}}
"""
);
var show = await Provider(http).GetShowAsync("7", ShowKind.Single, CancellationToken.None);
// У США записи есть, но сертификации в них нет — ответом становится следующая страна.
Assert.Equal("18+", show?.ContentRating);
}
[Fact]
public async Task GetShow_WithoutOptionalFields_FallsBackToPlaceholders()
{
var show = await Provider(FakeHttpClientFactory.Json("{}"))
.GetShowAsync("42", ShowKind.Series, CancellationToken.None);
Assert.NotNull(show);
Assert.Equal("42", show.ExternalId);
Assert.Equal("—", show.Title);
Assert.Null(show.Year);
Assert.Null(show.PosterUrl);
Assert.Null(show.ContentRating);
Assert.Empty(show.Genres!);
}
[Fact]
public async Task GetShow_ReturnsNull_WhenRequestFails()
{
var show = await Provider(FakeHttpClientFactory.Failing())
.GetShowAsync("42", ShowKind.Series, CancellationToken.None);
Assert.Null(show);
}
[Fact]
public async Task GetEpisode_ReadsStillAndAirDate()
{
var http = FakeHttpClientFactory.Json(
"""{"name":"Серия","overview":"о серии","still_path":"/s.jpg","air_date":"2001-06-07"}"""
);
var episode = await Provider(http).GetEpisodeAsync("42", 2, 3, CancellationToken.None);
Assert.Contains("/tv/42/season/2/episode/3", http.LastUrl);
Assert.NotNull(episode);
Assert.Equal("Серия", episode.Title);
Assert.Equal("о серии", episode.Overview);
Assert.Equal("https://image.tmdb.org/t/p/w300/s.jpg", episode.StillUrl);
Assert.Equal(new DateOnly(2001, 6, 7), episode.AirDate);
}
[Fact]
public async Task GetEpisode_ReturnsNull_WhenRequestFails()
{
var episode = await Provider(FakeHttpClientFactory.Failing())
.GetEpisodeAsync("42", 1, 1, CancellationToken.None);
Assert.Null(episode);
}
[Fact]
public async Task GetSeasonEpisodeCount_CountsEpisodes()
{
var http = FakeHttpClientFactory.Json("""{"episodes":[{"id":1},{"id":2},{"id":3}]}""");
var count = await Provider(http)
.GetSeasonEpisodeCountAsync("42", 2, CancellationToken.None);
Assert.Contains("/tv/42/season/2", http.LastUrl);
Assert.Equal(3, count);
}
[Fact]
public async Task GetSeasonEpisodeCount_ReturnsNull_WhenSectionMissing()
{
var count = await Provider(FakeHttpClientFactory.Json("{}"))
.GetSeasonEpisodeCountAsync("42", 9, CancellationToken.None);
Assert.Null(count);
}
[Fact]
public async Task GetSeasonNumbers_SkipsSpecialsAndSorts()
{
var http = FakeHttpClientFactory.Json(
"""{"seasons":[{"season_number":2},{"season_number":0},{"season_number":1},{"name":"без номера"}]}"""
);
var seasons = await Provider(http).GetSeasonNumbersAsync("42", CancellationToken.None);
Assert.Equal([1, 2], seasons);
}
[Fact]
public async Task GetSeasonNumbers_ReturnsEmpty_WhenRequestFails()
{
var seasons = await Provider(FakeHttpClientFactory.Failing())
.GetSeasonNumbersAsync("42", CancellationToken.None);
Assert.Empty(seasons);
}
}