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);
+ }
+}