using Microsoft.EntityFrameworkCore; using NSubstitute; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Library.DeleteShow; using TeleWave.Application.Maintenance.DeleteShowMedia; using TeleWave.Application.Media; using TeleWave.Application.Media.Register; using TeleWave.Application.Programming.Groups; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Media; using Xunit; namespace TeleWave.Integration.Tests; [Collection("postgres")] public sealed class TransactionIntegrationTests(PostgresFixture fixture) { [SkippableFact] public async Task RegisterMediaAsset_RollsBack_WhenFilePromoteFails() { Skip.IfNot(fixture.Available, "Docker недоступен"); var fileName = $"clip-{Guid.NewGuid():N}.mkv"; var storage = Substitute.For(); storage .PromoteToOriginalAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any() ) .Returns(Task.FromException(new IOException("disk full"))); await using var db = fixture.CreateContext(); var handler = new RegisterMediaAssetCommandHandler(db, storage); await Assert.ThrowsAsync(() => handler.Handle( new RegisterMediaAssetCommand(fileName, MediaSource.Upload, fileName), CancellationToken.None ) ); // Перенос файла упал — регистрация должна откатиться, чтобы не осталось orphan-строки. await using var verify = fixture.CreateContext(); var exists = await verify.MediaAssets.AnyAsync(a => a.OriginalFileName == fileName); Assert.False(exists); } [SkippableFact] public async Task DeleteShowMedia_AtomicallyRemovesEntriesAssetsAndEpisodes() { Skip.IfNot(fixture.Available, "Docker недоступен"); var channel = Channel.Create("c", $"c-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch); var show = Show.Create("Show", ShowKind.Series); var asset = MediaAsset.Register("ep.mkv", ".mkv", MediaSource.Upload); asset.MarkReady( new MediaReadyInfo( TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x" ) ); show.AddEpisode(asset.Id); var entry = ScheduleEntry.Program( channel.Id, asset.Id, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddMinutes(20), show.Id, 0 ); await using (var seed = fixture.CreateContext()) { seed.Channels.Add(channel); seed.Shows.Add(show); seed.MediaAssets.Add(asset); seed.ScheduleEntries.Add(entry); await seed.SaveChangesAsync(CancellationToken.None); } var storage = Substitute.For(); storage .DeleteAssetArtifactsAsync( Arg.Any(), Arg.Any(), Arg.Any() ) .Returns(Task.CompletedTask); await using (var db = fixture.CreateContext()) { var result = await new DeleteShowMediaCommandHandler( db, new ShowMediaEraser(db, storage) ).Handle(new DeleteShowMediaCommand(show.Id), CancellationToken.None); Assert.True(result.IsSuccess); } await using var verify = fixture.CreateContext(); Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id)); Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id)); var reloaded = await verify.Shows.Include(s => s.Episodes).FirstAsync(s => s.Id == show.Id); Assert.Empty(reloaded.Episodes); await storage .Received() .DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any()); } /// /// Шоу, стоящее в будущем эфире, удаляется с флагом «вырезать из эфира»: пересборка канала /// спасает не всегда — идущую запись она не трогает, а выключенный канал вещать не начнёт. /// [SkippableFact] public async Task DeleteShow_WithSchedule_CutsFutureEntries() { var channel = Channel.Create("Канал", $"cut-{Guid.NewGuid():N}", 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()) ).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)); } /// /// Удаление шоу «вместе с медиа»: ассеты, записи расписания и файлы уходят одной операцией. /// Проверяется на настоящем Postgres — ExecuteDelete и транзакции InMemory не умеет. /// [SkippableFact] public async Task DeleteShow_WithMedia_RemovesEverything() { var channel = Channel.Create("Канал", $"del-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch); var show = Show.Create("Шоу", ShowKind.Single); var asset = MediaAsset.Register("movie.mkv", ".mkv", MediaSource.Upload); show.AddEpisode(asset.Id); var entry = ScheduleEntry.Program( channel.Id, asset.Id, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddMinutes(20), show.Id, 0 ); await using (var seed = fixture.CreateContext()) { seed.Channels.Add(channel); seed.Shows.Add(show); seed.MediaAssets.Add(asset); seed.ScheduleEntries.Add(entry); await seed.SaveChangesAsync(CancellationToken.None); } var storage = Substitute.For(); 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, storage) ).Handle(new DeleteShowCommand(show.Id, WithMedia: 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.MediaAssets.AnyAsync(a => a.Id == asset.Id)); Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id)); await storage .Received() .DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any()); } }