using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Application.Tests.Metadata;
///
/// Довыгрузка метаданных серий: что берётся за номер серии, что происходит с кадром и когда
/// операция отказывается работать вовсе.
///
public class RefreshEpisodesTests
{
private static EpisodeMetadata Meta(string title, string? stillUrl = null) =>
new(title, "Описание", stillUrl, new DateOnly(2005, 3, 20));
private static (IMetadataProviderResolver Resolver, IMetadataProvider Provider) ProviderFor(
string key
)
{
var provider = Substitute.For();
var resolver = Substitute.For();
resolver.Resolve(key).Returns(provider);
return (resolver, provider);
}
private static RefreshShowEpisodesMetadataCommandHandler Handler(
IAppDbContext db,
IMetadataProviderResolver resolver,
IImageDownloader? downloader = null,
IImageStore? store = null
) =>
new(
db,
resolver,
downloader ?? Substitute.For(),
store ?? Substitute.For(),
NullLogger.Instance
);
/// Сериал с привязкой к источнику и одной серией на готовом ассете.
private static (Show Show, MediaAsset Asset) Linked(string fileName, bool withNumbers)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
var show = Show.Create("Симпсоны", ShowKind.Series);
show.ApplyMetadata("tmdb", "456", null, 2005, null);
var episode = show.AddEpisode(asset.Id);
if (withNumbers)
episode.SetNumbers(16, 21);
return (show, asset);
}
[Fact]
public async Task AppliesMetadata_AndSavesStillToRegistry()
{
var fixture = new TestDb();
var (show, asset) = Linked("s16e21.mkv", withNumbers: true);
await using (var seed = fixture.New())
{
seed.MediaAssets.Add(asset);
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
var (resolver, provider) = ProviderFor("tmdb");
provider
.GetEpisodeAsync("456", 16, 21, Arg.Any())
.Returns(Meta("The Father, the Son", "https://img/still.jpg"));
var downloader = Substitute.For();
downloader
.DownloadAsync("https://img/still.jpg", Arg.Any())
.Returns(new DownloadedImage([1, 2, 3], ".jpg"));
var store = Substitute.For();
await using var db = fixture.New();
var result = await Handler(db, resolver, downloader, store)
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value);
await using var check = fixture.New();
var stored = await check.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None);
var episode = stored.Episodes.Single();
Assert.Equal("The Father, the Son", episode.Title);
Assert.NotNull(episode.StillImageId);
// Кадр попадает в общий реестр изображений, а не в своё файловое поле.
Assert.Equal(1, await check.Images.CountAsync(CancellationToken.None));
await store
.Received(1)
.SaveAsync(
episode.StillImageId!.Value,
".jpg",
Arg.Any(),
Arg.Any()
);
}
[Fact]
public async Task TakesNumbersFromFileName_WhenNotSet()
{
var fixture = new TestDb();
var (show, asset) = Linked("Simpsons.S16E21.1080p.mkv", withNumbers: false);
await using (var seed = fixture.New())
{
seed.MediaAssets.Add(asset);
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
var (resolver, provider) = ProviderFor("tmdb");
provider
.GetEpisodeAsync("456", 16, 21, Arg.Any())
.Returns(Meta("Разобрано по имени"));
await using var db = fixture.New();
var result = await Handler(db, resolver)
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
Assert.Equal(1, result.Value);
await using var check = fixture.New();
var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single();
// Разобранные номера тут же сохраняются: второй раз имя файла разбирать незачем.
Assert.Equal(16, episode.Season);
Assert.Equal(21, episode.Episode);
}
[Fact]
public async Task SkipsEpisode_WhenNumbersUnknownOrSourceSilent()
{
var fixture = new TestDb();
var unparsable = MediaAsset.Register("случайное имя.mkv", ".mkv", MediaSource.Upload);
var known = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload);
var show = Show.Create("Сериал", ShowKind.Series);
show.ApplyMetadata("tmdb", "1", null, null, null);
show.AddEpisode(unparsable.Id);
show.AddEpisode(known.Id);
await using (var seed = fixture.New())
{
seed.MediaAssets.AddRange(unparsable, known);
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
var (resolver, provider) = ProviderFor("tmdb");
// Источник знает не всё: на распознанную серию он отвечает пустотой.
provider
.GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any())
.Returns((EpisodeMetadata?)null);
await using var db = fixture.New();
var result = await Handler(db, resolver)
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(0, result.Value);
// К нераспознанной серии источник не ходит вовсе — незачем.
await provider
.Received(1)
.GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any());
}
[Fact]
public async Task KeepsMetadata_WhenStillCannotBeDownloaded()
{
var fixture = new TestDb();
var (show, asset) = Linked("s01e01.mkv", withNumbers: true);
await using (var seed = fixture.New())
{
seed.MediaAssets.Add(asset);
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
var (resolver, provider) = ProviderFor("tmdb");
provider
.GetEpisodeAsync("456", Arg.Any(), Arg.Any(), Arg.Any())
.Returns(Meta("Без кадра", "https://img/broken.jpg"));
var downloader = Substitute.For();
downloader
.DownloadAsync(Arg.Any(), Arg.Any())
.Returns((DownloadedImage?)null);
await using var db = fixture.New();
var result = await Handler(db, resolver, downloader)
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
await db.SaveChangesAsync(CancellationToken.None);
// Недоступный кадр не повод отменять текст: серия обновлена, картинки просто нет.
Assert.Equal(1, result.Value);
await using var check = fixture.New();
var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single();
Assert.Equal("Без кадра", episode.Title);
Assert.Null(episode.StillImageId);
Assert.Equal(0, await check.Images.CountAsync(CancellationToken.None));
}
[Fact]
public async Task Fails_WhenShowIsMissingOrNotSeriesOrUnlinked()
{
var fixture = new TestDb();
var movie = Show.Create("Фильм", ShowKind.Single);
var unlinked = Show.Create("Сериал без источника", ShowKind.Series);
await using (var seed = fixture.New())
{
seed.Shows.AddRange(movie, unlinked);
await seed.SaveChangesAsync(CancellationToken.None);
}
var (resolver, _) = ProviderFor("tmdb");
await using var db = fixture.New();
var handler = Handler(db, resolver);
var missing = await handler.Handle(
new RefreshShowEpisodesMetadataCommand(Guid.NewGuid()),
CancellationToken.None
);
var notSeries = await handler.Handle(
new RefreshShowEpisodesMetadataCommand(movie.Id),
CancellationToken.None
);
var noSource = await handler.Handle(
new RefreshShowEpisodesMetadataCommand(unlinked.Id),
CancellationToken.None
);
Assert.False(missing.IsSuccess);
Assert.Equal(MetadataErrors.EpisodesForSeriesOnly, notSeries.Error);
Assert.Equal(MetadataErrors.NoLinkedSource, noSource.Error);
}
[Fact]
public async Task Fails_WhenLinkedProviderIsNotConfigured()
{
var fixture = new TestDb();
var show = Show.Create("Сериал", ShowKind.Series);
show.ApplyMetadata("omdb", "tt1", null, null, null);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
var resolver = Substitute.For();
// Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null.
resolver.Resolve(Arg.Any()).Returns((IMetadataProvider?)null);
await using var db = fixture.New();
var result = await Handler(db, resolver)
.Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error);
}
}