Update README.md with additional SonarCloud badges and improve CI workflow for coverage reporting
ci / build-backend (push) Successful in 2m34s
ci / build-frontend (push) Successful in 41s
ci / tests (push) Successful in 2m55s
ci / sonar (push) Successful in 5m21s

Enhanced the README.md file by adding new SonarCloud badges for coverage, bugs, code smells, security rating, and maintainability rating. Updated the CI workflow to remove coverage collection from the test step, as it is now handled by SonarCloud, streamlining the process and ensuring accurate badge representation.
This commit is contained in:
Leonid Pershin
2026-07-27 03:19:18 +03:00
parent 464355434b
commit 790d01b587
22 changed files with 2891 additions and 99 deletions
@@ -0,0 +1,210 @@
using Microsoft.Extensions.Options;
using TeleWave.Application.Tests.TestSupport;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Metadata;
using Xunit;
namespace TeleWave.Application.Tests.Metadata;
/// <summary>
/// Разбор ответов OMDb. Особенности источника, ради которых тесты и написаны: «N/A» вместо
/// отсутствующего значения, флаг <c>Response</c> вместо кода ошибки и жанры одной строкой.
/// </summary>
public class OmdbMetadataProviderTests
{
private static OmdbMetadataProvider Provider(FakeHttpClientFactory http) =>
new(
http,
Options.Create(new MetadataOptions { Omdb = new OmdbOptions { ApiKey = "key" } })
);
[Fact]
public async Task SearchShows_KeepsOnlyShowsWithId()
{
var http = FakeHttpClientFactory.Json(
"""
{"Search":[
{"imdbID":"tt1","Title":"Сериал","Year":"20012005","Type":"series","Poster":"http://p/1.jpg"},
{"imdbID":"tt2","Title":"Серия","Type":"episode"},
{"Title":"Без идентификатора","Type":"movie"},
{"imdbID":"tt3","Title":"Фильм","Year":"N/A","Type":"movie","Poster":"N/A"}
]}
"""
);
var found = await Provider(http)
.SearchShowsAsync("сериал", ShowKind.Series, CancellationToken.None);
Assert.Contains("type=series", http.LastUrl);
Assert.Equal(2, found.Count);
Assert.Equal("tt1", found[0].ExternalId);
Assert.Equal(2001, found[0].Year);
Assert.Equal("http://p/1.jpg", found[0].PosterUrl);
Assert.Equal(ShowKind.Series, found[0].Kind);
// Отдельные серии просачиваются в выдачу и шоу не годятся, запись без imdbID — тоже.
Assert.Equal("tt3", found[1].ExternalId);
Assert.Equal(ShowKind.Single, found[1].Kind);
Assert.Null(found[1].Year);
Assert.Null(found[1].PosterUrl);
}
[Fact]
public async Task SearchShows_Single_AsksForMovies()
{
var http = FakeHttpClientFactory.Json("{}");
var found = await Provider(http)
.SearchShowsAsync("фильм", ShowKind.Single, CancellationToken.None);
Assert.Contains("type=movie", http.LastUrl);
Assert.Empty(found);
}
[Fact]
public async Task GetShow_ReadsRatingAndSplitsGenres()
{
var http = FakeHttpClientFactory.Json(
"""
{"Response":"True","Title":"Сериал","Year":"20012005","Plot":"описание",
"Poster":"http://p/1.jpg","Genre":"Action, Sci-Fi","Rated":"TV-14"}
"""
);
var show = await Provider(http)
.GetShowAsync("tt1", ShowKind.Series, CancellationToken.None);
Assert.Contains("i=tt1", http.LastUrl);
Assert.NotNull(show);
Assert.Equal("tt1", show.ExternalId);
Assert.Equal("Сериал", show.Title);
Assert.Equal(2001, show.Year);
Assert.Equal("описание", show.Overview);
Assert.Equal(new[] { "Action", "Sci-Fi" }, show.Genres);
Assert.Equal("TV-14", show.ContentRating);
}
[Fact]
public async Task GetShow_TreatsNotAvailableAsMissing()
{
var http = FakeHttpClientFactory.Json(
"""{"Response":"True","Title":"N/A","Plot":"N/A","Poster":"N/A","Genre":"N/A","Rated":"N/A"}"""
);
var show = await Provider(http)
.GetShowAsync("tt1", ShowKind.Single, CancellationToken.None);
Assert.NotNull(show);
Assert.Equal("—", show.Title);
Assert.Null(show.Overview);
Assert.Null(show.PosterUrl);
Assert.Null(show.ContentRating);
Assert.Empty(show.Genres!);
}
[Fact]
public async Task GetShow_ReturnsNull_WhenResponseIsFalse()
{
var http = FakeHttpClientFactory.Json(
"""{"Response":"False","Error":"Movie not found!"}"""
);
var show = await Provider(http)
.GetShowAsync("tt0", ShowKind.Series, CancellationToken.None);
Assert.Null(show);
}
[Fact]
public async Task GetShow_ReturnsNull_WhenRequestFails()
{
var show = await Provider(FakeHttpClientFactory.Failing())
.GetShowAsync("tt1", ShowKind.Series, CancellationToken.None);
Assert.Null(show);
}
[Fact]
public async Task GetEpisode_ReadsTitleAndReleaseDate()
{
var http = FakeHttpClientFactory.Json(
"""
{"Response":"True","Title":"Серия","Plot":"о серии","Poster":"http://p/s.jpg",
"Released":"07 Jun 2001"}
"""
);
var episode = await Provider(http).GetEpisodeAsync("tt1", 2, 3, CancellationToken.None);
Assert.Contains("Season=2", http.LastUrl);
Assert.Contains("Episode=3", http.LastUrl);
Assert.NotNull(episode);
Assert.Equal("Серия", episode.Title);
Assert.Equal("http://p/s.jpg", episode.StillUrl);
Assert.Equal(new DateOnly(2001, 6, 7), episode.AirDate);
}
[Fact]
public async Task GetEpisode_ReturnsNull_WhenResponseIsFalse()
{
var http = FakeHttpClientFactory.Json("""{"Response":"False"}""");
var episode = await Provider(http).GetEpisodeAsync("tt1", 9, 9, CancellationToken.None);
Assert.Null(episode);
}
[Fact]
public async Task GetSeasonEpisodeCount_CountsEpisodes()
{
var http = FakeHttpClientFactory.Json(
"""{"Response":"True","Episodes":[{"Episode":"1"},{"Episode":"2"}]}"""
);
var count = await Provider(http)
.GetSeasonEpisodeCountAsync("tt1", 2, CancellationToken.None);
Assert.Equal(2, count);
}
[Fact]
public async Task GetSeasonEpisodeCount_ReturnsNull_WhenSeasonMissing()
{
var http = FakeHttpClientFactory.Json("""{"Response":"True"}""");
var count = await Provider(http)
.GetSeasonEpisodeCountAsync("tt1", 9, CancellationToken.None);
Assert.Null(count);
}
[Fact]
public async Task GetSeasonNumbers_NumbersSeasonsContinuously()
{
// Перечня сезонов OMDb не отдаёт — только их количество строкой.
var http = FakeHttpClientFactory.Json("""{"Response":"True","totalSeasons":"3"}""");
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
Assert.Equal(new[] { 1, 2, 3 }, seasons);
}
[Fact]
public async Task GetSeasonNumbers_ReturnsEmpty_WhenTotalIsNotANumber()
{
var http = FakeHttpClientFactory.Json("""{"Response":"True","totalSeasons":"N/A"}""");
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
Assert.Empty(seasons);
}
[Fact]
public async Task GetSeasonNumbers_ReturnsEmpty_WhenResponseIsFalse()
{
var http = FakeHttpClientFactory.Json("""{"Response":"False"}""");
var seasons = await Provider(http).GetSeasonNumbersAsync("tt1", CancellationToken.None);
Assert.Empty(seasons);
}
}
@@ -0,0 +1,250 @@
using Microsoft.Extensions.Options;
using TeleWave.Application.Tests.TestSupport;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Metadata;
using Xunit;
namespace TeleWave.Application.Tests.Metadata;
/// <summary>
/// Разбор ответов TMDb. Наружу тесты не ходят — HTTP подменён, проверяем и собранный адрес
/// (сериалы и фильмы живут в разных пространствах API), и то, что из ответа вычитано.
/// </summary>
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(new[] { "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":"Фильм","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);
}
[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(new[] { 1, 2 }, seasons);
}
[Fact]
public async Task GetSeasonNumbers_ReturnsEmpty_WhenRequestFails()
{
var seasons = await Provider(FakeHttpClientFactory.Failing())
.GetSeasonNumbersAsync("42", CancellationToken.None);
Assert.Empty(seasons);
}
}