Add error handling for future schedule conflicts and media asset usage
ci / build-backend (push) Successful in 2m5s
ci / build-frontend (push) Successful in 40s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 7m39s

Introduced new error types for handling conflicts when attempting to delete shows or media assets that are currently scheduled or in use. Updated the DeleteShowCommandHandler and DeleteMediaAssetCommandHandler to check for these conditions and return appropriate error responses. This enhances the robustness of the application by preventing unintended deletions and improving user feedback.
This commit is contained in:
Leonid Pershin
2026-07-27 02:18:25 +03:00
parent b80163b6b4
commit 2045b1a3f2
7 changed files with 288 additions and 3 deletions
@@ -21,6 +21,16 @@ public sealed class DeleteShowCommandHandler(
if (show is null) if (show is null)
return Result.Failure(ShowErrors.NotFound); 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( await groupCleaner.RemoveElementAsync(
GroupElementKind.Show, GroupElementKind.Show,
@@ -28,7 +38,6 @@ public sealed class DeleteShowCommandHandler(
cancellationToken cancellationToken
); );
// TODO(этап 2+): запретить удаление шоу, пока оно привязано к каналу или будущему расписанию.
dbContext.Shows.Remove(show); dbContext.Shows.Remove(show);
return Result.Success(); return Result.Success();
} }
@@ -16,6 +16,11 @@ public static class ShowErrors
"Больше одной серии бывает только у сериала." "Больше одной серии бывает только у сериала."
); );
public static readonly Error InFutureSchedule = Error.Conflict(
"Shows.InFutureSchedule",
"Шоу стоит в уже сгенерированном эфире. Пересоберите расписание канала и повторите."
);
public static readonly Error AssetNotFound = Error.NotFound( public static readonly Error AssetNotFound = Error.NotFound(
"Shows.AssetNotFound", "Shows.AssetNotFound",
"Медиа-ассет для серии не найден." "Медиа-ассет для серии не найден."
@@ -20,7 +20,21 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed
if (asset is null) if (asset is null)
return Result.Failure(MediaErrors.NotFound); 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-файл на диске // Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске
// безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты. // безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты.
var extension = asset.OriginalExtension; var extension = asset.OriginalExtension;
@@ -34,6 +34,11 @@ public static class MediaErrors
"Исходный файл не найден в хранилище." "Исходный файл не найден в хранилище."
); );
public static readonly Error InUse = Error.Conflict(
"Media.InUse",
"Ассет занят: он стоит в расписании канала или выбран заполнителем эфира."
);
public static readonly Error DuplicateFileName = Error.Conflict( public static readonly Error DuplicateFileName = Error.Conflict(
"Media.DuplicateFileName", "Media.DuplicateFileName",
"Файл с таким именем уже загружен." "Файл с таким именем уже загружен."
@@ -20,7 +20,23 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext)
if (group is null) if (group is null)
return Result.Failure(GroupErrors.NotFound); 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); dbContext.Groups.Remove(group);
return Result.Success(); return Result.Success();
} }
@@ -16,6 +16,11 @@ public static class GroupErrors
"Шоу или коллекция не найдены." "Шоу или коллекция не найдены."
); );
public static readonly Error InUse = Error.Conflict(
"Groups.InUse",
"Группа используется в сетке: на неё ссылается слот, врезка стыка или запасная группа шаблона."
);
public static readonly Error FilterNotSet = Error.Validation( public static readonly Error FilterNotSet = Error.Validation(
"Groups.FilterNotSet", "Groups.FilterNotSet",
"У группы не задано правило набора." "У группы не задано правило набора."
@@ -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;
/// <summary>
/// Удаление занятой сущности: ссылки на шоу, ассет и группу живут в таблицах без внешних ключей
/// (или с Restrict), поэтому БД такое удаление либо пропустит с висячей ссылкой, либо уронит
/// исключением. Проверяем, что до неё дело не доходит — возвращается управляемая ошибка.
/// </summary>
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);
}
/// <summary>Отыгранный эфир шоу не держит: иначе его нельзя было бы удалить никогда.</summary>
[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<IMediaStorage>();
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<IMediaStorage>()
).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);
}
/// <summary>Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно.</summary>
[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);
}
}