Add bulk poster filling endpoint and enhance show deletion options
Introduced a new endpoint for bulk filling collection posters, allowing for automatic assignment of posters to collections without existing images. Updated the DeleteShowCommand to include an option for cutting shows from future airings, enhancing the deletion process. Refactored related components and API calls to support these features, ensuring a seamless user experience. Additionally, updated localization strings to reflect the new functionalities and adjusted tests to verify correct behavior.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Application.Library.Collections.FillCollectionPosters;
|
||||
using TeleWave.Application.Tests.Support;
|
||||
using TeleWave.Domain.Images;
|
||||
using TeleWave.Domain.Library;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Application.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Постер коллекции по её частям. Правило простое и объяснимое: обложка первой части — франшиза
|
||||
/// узнаётся именно по ней, а коллекция без картинки теряется в списке.
|
||||
/// </summary>
|
||||
public class CollectionPosterTests
|
||||
{
|
||||
private static Show WithPoster(string name, Guid? imageId)
|
||||
{
|
||||
var show = Show.Create(name, ShowKind.Single);
|
||||
if (imageId is { } id)
|
||||
show.ApplyMetadata("tmdb", name, null, 1990, id);
|
||||
return show;
|
||||
}
|
||||
|
||||
private static async Task<int> FillAsync(TestDb fixture, params Guid[] ids)
|
||||
{
|
||||
await using var db = fixture.New();
|
||||
var result = await new FillCollectionPostersCommandHandler(
|
||||
db,
|
||||
new CollectionPosterPicker(db)
|
||||
).Handle(
|
||||
new FillCollectionPostersCommand(ids.Length > 0 ? ids : null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TakesPosterOfTheFirstPart()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var poster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Терминатор");
|
||||
var second = Image.Create(ImageCategory.ShowPoster, ".jpg", "Терминатор 2");
|
||||
|
||||
var first = WithPoster("Терминатор", poster.Id);
|
||||
var sequel = WithPoster("Терминатор 2", second.Id);
|
||||
var collection = Collection.Create("Терминатор");
|
||||
collection.AddShow(first.Id);
|
||||
collection.AddShow(sequel.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Images.AddRange(poster, second);
|
||||
seed.Shows.AddRange(first, sequel);
|
||||
seed.Collections.Add(collection);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(1, await FillAsync(fixture));
|
||||
|
||||
await using var db = fixture.New();
|
||||
var stored = await db.Collections.SingleAsync(CancellationToken.None);
|
||||
Assert.Equal(poster.Id, stored.PosterImageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipsFirstPartWithoutPoster()
|
||||
{
|
||||
// У первой части постера нет — берём следующую с обложкой, а не оставляем коллекцию пустой.
|
||||
var fixture = new TestDb();
|
||||
var poster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Вторая часть");
|
||||
|
||||
var first = WithPoster("Первая часть", null);
|
||||
var second = WithPoster("Вторая часть", poster.Id);
|
||||
var collection = Collection.Create("Цикл");
|
||||
collection.AddShow(first.Id);
|
||||
collection.AddShow(second.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Images.Add(poster);
|
||||
seed.Shows.AddRange(first, second);
|
||||
seed.Collections.Add(collection);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(1, await FillAsync(fixture));
|
||||
|
||||
await using var db = fixture.New();
|
||||
Assert.Equal(
|
||||
poster.Id,
|
||||
(await db.Collections.SingleAsync(CancellationToken.None)).PosterImageId
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeepsPosterChosenByHand()
|
||||
{
|
||||
// Выбор человека автоматика не перебивает: он мог поставить кадр, а не обложку первой части.
|
||||
var fixture = new TestDb();
|
||||
var chosen = Image.Create(ImageCategory.ShowPoster, ".jpg", "Выбранный");
|
||||
var showPoster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Часть");
|
||||
|
||||
var show = WithPoster("Часть", showPoster.Id);
|
||||
var collection = Collection.Create("Цикл");
|
||||
collection.AddShow(show.Id);
|
||||
collection.SetPosterImage(chosen.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Images.AddRange(chosen, showPoster);
|
||||
seed.Shows.Add(show);
|
||||
seed.Collections.Add(collection);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(0, await FillAsync(fixture));
|
||||
|
||||
await using var db = fixture.New();
|
||||
Assert.Equal(
|
||||
chosen.Id,
|
||||
(await db.Collections.SingleAsync(CancellationToken.None)).PosterImageId
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeavesCollectionWithoutPostersAlone()
|
||||
{
|
||||
// Ни у одной части нет обложки — ставить нечего, и это не ошибка.
|
||||
var fixture = new TestDb();
|
||||
var show = WithPoster("Часть", null);
|
||||
var collection = Collection.Create("Цикл");
|
||||
collection.AddShow(show.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.Collections.Add(collection);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(0, await FillAsync(fixture));
|
||||
|
||||
await using var db = fixture.New();
|
||||
Assert.Null((await db.Collections.SingleAsync(CancellationToken.None)).PosterImageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FillsOnlyRequestedCollections()
|
||||
{
|
||||
var fixture = new TestDb();
|
||||
var poster = Image.Create(ImageCategory.ShowPoster, ".jpg", "Часть");
|
||||
var show = WithPoster("Часть", poster.Id);
|
||||
|
||||
var wanted = Collection.Create("Нужная");
|
||||
wanted.AddShow(show.Id);
|
||||
var other = Collection.Create("Другая");
|
||||
other.AddShow(show.Id);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Images.Add(poster);
|
||||
seed.Shows.Add(show);
|
||||
seed.Collections.AddRange(wanted, other);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.Equal(1, await FillAsync(fixture, wanted.Id));
|
||||
|
||||
await using var db = fixture.New();
|
||||
Assert.NotNull((await db.Collections.SingleAsync(c => c.Id == wanted.Id)).PosterImageId);
|
||||
Assert.Null((await db.Collections.SingleAsync(c => c.Id == other.Id)).PosterImageId);
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,17 @@ public class DeleteGuardsTests
|
||||
var fixture = new TestDb();
|
||||
var show = Show.Create("A", ShowKind.Series);
|
||||
var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload);
|
||||
var channel = Channel.Create("Первый", "first", T0);
|
||||
var future = DateTimeOffset.UtcNow.AddHours(1);
|
||||
|
||||
await using (var seed = fixture.New())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.Channels.Add(channel);
|
||||
seed.ScheduleEntries.Add(
|
||||
ScheduleEntry.Program(
|
||||
Guid.NewGuid(),
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
future,
|
||||
future.AddMinutes(20),
|
||||
@@ -63,7 +65,9 @@ public class DeleteGuardsTests
|
||||
);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(ShowErrors.InFutureSchedule, result.Error);
|
||||
Assert.Equal(ShowErrors.InFutureSchedule(string.Empty).Code, result.Error.Code);
|
||||
// В тексте — канал и время: по ним видно, что именно пересобирать.
|
||||
Assert.Contains("Первый", result.Error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Application.Library.Collections.Suggest;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Groups.Suggest;
|
||||
@@ -142,7 +143,8 @@ public class SuggestionQueriesTests
|
||||
await using var db = fixture.New();
|
||||
var result = await new CreateCollectionFromSuggestionCommandHandler(
|
||||
db,
|
||||
new CollectionSuggestionBuilder(db)
|
||||
new CollectionSuggestionBuilder(db),
|
||||
new CollectionPosterPicker(db)
|
||||
).Handle(
|
||||
new CreateCollectionFromSuggestionCommand("name:нет-такого"),
|
||||
CancellationToken.None
|
||||
@@ -166,7 +168,8 @@ public class SuggestionQueriesTests
|
||||
await using var db = fixture.New();
|
||||
var handler = new CreateCollectionFromSuggestionCommandHandler(
|
||||
db,
|
||||
new CollectionSuggestionBuilder(db)
|
||||
new CollectionSuggestionBuilder(db),
|
||||
new CollectionPosterPicker(db)
|
||||
);
|
||||
|
||||
var created = await handler.Handle(
|
||||
|
||||
@@ -114,6 +114,66 @@ public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
||||
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Шоу, стоящее в будущем эфире, удаляется с флагом «вырезать из эфира»: пересборка канала
|
||||
/// спасает не всегда — идущую запись она не трогает, а выключенный канал вещать не начнёт.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task DeleteShow_WithSchedule_CutsFutureEntries()
|
||||
{
|
||||
var channel = Channel.Create("Канал", "kanal", DateTimeOffset.UnixEpoch);
|
||||
var show = Show.Create("Шоу", ShowKind.Single);
|
||||
var asset = MediaAsset.Register("live.mkv", ".mkv", MediaSource.Upload);
|
||||
show.AddEpisode(asset.Id);
|
||||
|
||||
// Запись идёт прямо сейчас: пересборка её не удаляет, а значит и шоу не освобождает.
|
||||
var running = ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
DateTimeOffset.UtcNow.AddMinutes(15),
|
||||
show.Id,
|
||||
0
|
||||
);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.ScheduleEntries.Add(running);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
var result = await new DeleteShowCommandHandler(
|
||||
db,
|
||||
new GroupMembershipCleaner(
|
||||
db,
|
||||
new GroupStatsService(
|
||||
db,
|
||||
new GroupElementResolver(db),
|
||||
new DynamicGroupResolver(
|
||||
new GroupFilterMatcher(db),
|
||||
new GroupElementResolver(db)
|
||||
)
|
||||
)
|
||||
),
|
||||
new ShowMediaEraser(db, Substitute.For<IMediaStorage>())
|
||||
).Handle(new DeleteShowCommand(show.Id, WithSchedule: true), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
Assert.False(await verify.Shows.AnyAsync(s => s.Id == show.Id));
|
||||
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.ShowId == show.Id));
|
||||
// Медиа не просили — оно осталось.
|
||||
Assert.True(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаление шоу «вместе с медиа»: ассеты, записи расписания и файлы уходят одной операцией.
|
||||
/// Проверяется на настоящем Postgres — <c>ExecuteDelete</c> и транзакции InMemory не умеет.
|
||||
|
||||
Reference in New Issue
Block a user