diff --git a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs index 9122ff0..a97eb65 100644 --- a/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/DeleteShow/DeleteShowCommandHandler.cs @@ -21,6 +21,16 @@ public sealed class DeleteShowCommandHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); + // Уже сгенерированный эфир ссылается на шоу без внешнего ключа: удали его — и зритель + // получит запись без названия, а раздача упрётся в удалённые каскадом серии. Прошедшие + // записи не держим: они лишь теряют название в EPG, а иначе шоу было бы не удалить никогда. + var onAir = await dbContext.ScheduleEntries.AnyAsync( + e => e.ShowId == command.ShowId && e.EndsAtUtc > DateTimeOffset.UtcNow, + cancellationToken + ); + if (onAir) + return Result.Failure(ShowErrors.InFutureSchedule); + // Позиции коллекций уходят каскадом БД, позиции групп — вручную: ссылка группы полиморфна. await groupCleaner.RemoveElementAsync( GroupElementKind.Show, @@ -28,7 +38,6 @@ public sealed class DeleteShowCommandHandler( cancellationToken ); - // TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию. dbContext.Shows.Remove(show); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Library/ShowErrors.cs b/backend/src/TeleWave.Application/Library/ShowErrors.cs index e9f25df..3875ed2 100644 --- a/backend/src/TeleWave.Application/Library/ShowErrors.cs +++ b/backend/src/TeleWave.Application/Library/ShowErrors.cs @@ -16,6 +16,11 @@ public static class ShowErrors "Больше одной серии бывает только у сериала." ); + public static readonly Error InFutureSchedule = Error.Conflict( + "Shows.InFutureSchedule", + "Шоу стоит в уже сгенерированном эфире. Пересоберите расписание канала и повторите." + ); + public static readonly Error AssetNotFound = Error.NotFound( "Shows.AssetNotFound", "Медиа-ассет для серии не найден." diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs index d03bab2..18b29fb 100644 --- a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs @@ -20,7 +20,21 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed if (asset is null) return Result.Failure(MediaErrors.NotFound); - // TODO(этап 2): запретить удаление, пока ассет используется в расписании/пуле канала. + // Записи расписания и заполнитель канала ссылаются на ассет без внешнего ключа, поэтому БД + // такое удаление пропустила бы — и раздача HLS упёрлась бы в отсутствующие сегменты уже + // в эфире. Проверяем и прошедшие записи тоже: их подчищает генератор, а до тех пор строка + // с висячей ссылкой ломает EPG и трейс. + var inSchedule = await dbContext.ScheduleEntries.AnyAsync( + e => e.MediaAssetId == asset.Id, + cancellationToken + ); + var isFiller = await dbContext.Channels.AnyAsync( + c => c.FillerAssetId == asset.Id, + cancellationToken + ); + if (inSchedule || isFiller) + return Result.Failure(MediaErrors.InUse); + // Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске // безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты. var extension = asset.OriginalExtension; diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs index ebaa324..c244389 100644 --- a/backend/src/TeleWave.Application/Media/MediaErrors.cs +++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs @@ -34,6 +34,11 @@ public static class MediaErrors "Исходный файл не найден в хранилище." ); + public static readonly Error InUse = Error.Conflict( + "Media.InUse", + "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира." + ); + public static readonly Error DuplicateFileName = Error.Conflict( "Media.DuplicateFileName", "Файл с таким именем уже загружен." diff --git a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs index a1bd8f9..0810d0c 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -20,7 +20,23 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext) if (group is null) return Result.Failure(GroupErrors.NotFound); - // TODO(срез 1C): запретить удаление, пока на группу ссылается слот сетки. + // Слот и врезка стыка держат группу внешним ключом с Restrict — без этой проверки удаление + // упало бы исключением БД вместо управляемой ошибки. Запасная группа шаблона внешнего ключа + // не имеет, и там висячая ссылка проявилась бы только в эфире: генератор молча остался бы + // без замены для пустого слота. + var used = + await dbContext.Slots.AnyAsync(s => s.GroupId == group.Id, cancellationToken) + || await dbContext.JunctionElements.AnyAsync( + e => e.GroupId == group.Id, + cancellationToken + ) + || await dbContext.ScheduleTemplates.AnyAsync( + t => t.FallbackGroupId == group.Id, + cancellationToken + ); + if (used) + return Result.Failure(GroupErrors.InUse); + dbContext.Groups.Remove(group); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs index e663ded..a8c0c1e 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -16,6 +16,11 @@ public static class GroupErrors "Шоу или коллекция не найдены." ); + public static readonly Error InUse = Error.Conflict( + "Groups.InUse", + "Группа используется в сетке: на неё ссылается слот, врезка стыка или запасная группа шаблона." + ); + public static readonly Error FilterNotSet = Error.Validation( "Groups.FilterNotSet", "У группы не задано правило набора." diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs new file mode 100644 index 0000000..27339e0 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -0,0 +1,231 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Library; +using TeleWave.Application.Library.DeleteShow; +using TeleWave.Application.Media; +using TeleWave.Application.Media.Delete; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Groups.DeleteGroup; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Library; + +/// +/// Удаление занятой сущности: ссылки на шоу, ассет и группу живут в таблицах без внешних ключей +/// (или с Restrict), поэтому БД такое удаление либо пропустит с висячей ссылкой, либо уронит +/// исключением. Проверяем, что до неё дело не доходит — возвращается управляемая ошибка. +/// +public class DeleteGuardsTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) => + new(db, new GroupStatsService(db, new GroupElementResolver(db))); + + [Fact] + public async Task DeleteShow_InFutureSchedule_Fails() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload); + var future = DateTimeOffset.UtcNow.AddHours(1); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add( + ScheduleEntry.Program( + Guid.NewGuid(), + asset.Id, + future, + future.AddMinutes(20), + show.Id, + 0 + ) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + new DeleteShowCommand(show.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(ShowErrors.InFutureSchedule, result.Error); + } + + /// Отыгранный эфир шоу не держит: иначе его нельзя было бы удалить никогда. + [Fact] + public async Task DeleteShow_OnlyPastSchedule_Succeeds() + { + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Series); + var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add( + ScheduleEntry.Program(Guid.NewGuid(), asset.Id, T0, T0.AddMinutes(20), show.Id, 0) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle( + new DeleteShowCommand(show.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task DeleteMediaAsset_InSchedule_Fails() + { + var fixture = new TestDb(); + var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Upload); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.Add( + ScheduleEntry.Ad(Guid.NewGuid(), asset.Id, T0, T0.AddMinutes(1)) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var storage = Substitute.For(); + await using var db = fixture.New(); + var result = await new DeleteMediaAssetCommandHandler(db, storage).Handle( + new DeleteMediaAssetCommand(asset.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(MediaErrors.InUse, result.Error); + // Файлы не тронуты: до чистки хранилища дело не дошло. + await storage + .DidNotReceiveWithAnyArgs() + .DeleteAssetArtifactsAsync(default, default!, default); + } + + [Fact] + public async Task DeleteMediaAsset_ChannelFiller_Fails() + { + var fixture = new TestDb(); + var asset = MediaAsset.Register("filler.mkv", ".mkv", MediaSource.Upload); + var channel = Channel.Create("c", "c", T0); + channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, asset.Id); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteMediaAssetCommandHandler( + db, + Substitute.For() + ).Handle(new DeleteMediaAssetCommand(asset.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(MediaErrors.InUse, result.Error); + } + + [Fact] + public async Task DeleteGroup_UsedBySlot_Fails() + { + var fixture = new TestDb(); + var group = Group.Create("G"); + var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); + var layer = template.AddLayer("Базовый", 10); + var slot = layer.AddSlot("Блок", new TimeOnly(6, 0), 60); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + group.Id, + null, + null, + SlotBlockMode.FillSlot, + 1, + OverflowPolicy.ContinueNext + ) + ); + + await using (var seed = fixture.New()) + { + seed.Groups.Add(group); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteGroupCommandHandler(db).Handle( + new DeleteGroupCommand(group.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(GroupErrors.InUse, result.Error); + } + + /// Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно. + [Fact] + public async Task DeleteGroup_UsedAsTemplateFallback_Fails() + { + var fixture = new TestDb(); + var group = Group.Create("G"); + var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка"); + template.SetFallbackGroup(group.Id); + + await using (var seed = fixture.New()) + { + seed.Groups.Add(group); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteGroupCommandHandler(db).Handle( + new DeleteGroupCommand(group.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(GroupErrors.InUse, result.Error); + } + + [Fact] + public async Task DeleteGroup_Unreferenced_Succeeds() + { + var fixture = new TestDb(); + var group = Group.Create("G"); + + await using (var seed = fixture.New()) + { + seed.Groups.Add(group); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new DeleteGroupCommandHandler(db).Handle( + new DeleteGroupCommand(group.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + } +}