Enhance metadata handling by introducing ShowKind parameter across various components. Update SearchShowMetadataQuery and related handlers to support querying by show type, ensuring accurate metadata retrieval for series and films. Implement validation to prevent episodes from being associated with non-series shows. Update frontend API to include ShowKind in metadata search requests, improving overall functionality and user experience.
ci / build-backend (push) Successful in 3m42s
ci / build-frontend (push) Successful in 51s
ci / tests (push) Successful in 2m10s
ci / sonar (push) Successful in 6m40s

This commit is contained in:
Leonid Pershin
2026-07-26 22:29:16 +03:00
parent fdb0f321e4
commit b0740503bb
14 changed files with 327 additions and 40 deletions
@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Metadata;
public class ApplyShowMetadataTests
{
[Theory]
[InlineData(ShowKind.Series)]
[InlineData(ShowKind.Single)]
public async Task PassesShowKindToProvider(ShowKind kind)
{
// Тип определяет, в какое пространство идентификаторов пойдёт TMDb, и берётся он у самого
// шоу, а не из команды: иначе запросом можно было бы записать сериалу метаданные
// одноимённого фильма.
var arranged = await ArrangeAsync(kind);
Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null));
var result = await ApplyAsync(arranged);
Assert.True(result);
await arranged
.Provider.Received(1)
.GetShowAsync("42", kind, Arg.Any<CancellationToken>());
}
[Fact]
public async Task AppliesRecognizedRating()
{
var arranged = await ArrangeAsync(ShowKind.Single);
Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null, null, "PG-13"));
await ApplyAsync(arranged);
Assert.Equal(ShowAudience.Pg13, await SavedAudienceAsync(arranged));
}
[Fact]
public async Task KeepsRating_WhenSourceGivesNothingUsable()
{
// «Not Rated» — это отсутствие данных. Снять им проставленный рейтинг нельзя: обновление
// метаданных тихо открыло бы взрослому шоу дорогу в детское время.
var arranged = await ArrangeAsync(ShowKind.Single, ShowAudience.R);
Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null, null, "Not Rated"));
await ApplyAsync(arranged);
Assert.Equal(ShowAudience.R, await SavedAudienceAsync(arranged));
}
[Fact]
public async Task Fails_ForInterstitial()
{
var arranged = await ArrangeAsync(ShowKind.Interstitial);
Assert.False(await ApplyAsync(arranged));
await arranged
.Provider.DidNotReceive()
.GetShowAsync(
Arg.Any<string>(),
Arg.Any<ShowKind>(),
Arg.Any<CancellationToken>()
);
}
private static void Respond(IMetadataProvider provider, ShowMetadata meta) =>
provider
.GetShowAsync("42", Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
.Returns(meta);
private static async Task<bool> ApplyAsync(Arranged arranged)
{
await using var db = arranged.Fixture.New();
var handler = new ApplyShowMetadataCommandHandler(
db,
arranged.Resolver,
Substitute.For<IImageDownloader>(),
Substitute.For<IImageStore>(),
new GenreMatcher(db)
);
var result = await handler.Handle(
new ApplyShowMetadataCommand(arranged.ShowId, "tmdb", "42"),
CancellationToken.None
);
// В обычном пути сохраняет UnitOfWorkBehavior; при прямом вызове хендлера его нет.
if (result.IsSuccess)
await db.SaveChangesAsync(CancellationToken.None);
return result.IsSuccess;
}
private static async Task<ShowAudience?> SavedAudienceAsync(Arranged arranged)
{
await using var db = arranged.Fixture.New();
return (await db.Shows.FirstAsync(s => s.Id == arranged.ShowId)).Audience;
}
private sealed record Arranged(
TestDb Fixture,
IMetadataProvider Provider,
IMetadataProviderResolver Resolver,
Guid ShowId
);
private static async Task<Arranged> ArrangeAsync(ShowKind kind, ShowAudience? audience = null)
{
var fixture = new TestDb();
var show = Show.Create("A", kind);
show.SetAudience(audience);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
var provider = Substitute.For<IMetadataProvider>();
var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve("tmdb").Returns(provider);
return new Arranged(fixture, provider, resolver, show.Id);
}
}
@@ -50,6 +50,29 @@ public class FindMissingEpisodesTests
Assert.Equal([2, 3], s2.Missing);
}
[Fact]
public async Task Fails_ForNonSeries()
{
// У полнометражки сезонов нет, и запрос ушёл бы в /tv/{id} с идентификатором фильма.
var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Single);
show.ApplyMetadata("tmdb", "123", null, 2000, null);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
var resolver = Substitute.For<IMetadataProviderResolver>();
await using var db = fixture.New();
var result = await new FindMissingEpisodesQueryHandler(db, resolver).Handle(
new FindMissingEpisodesQuery(show.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task Fails_WhenNoLinkedSource()
{