From f924891f3b7351c8ada1ae594508b00e5d70a4ef Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 28 Jul 2026 13:19:30 +0300 Subject: [PATCH] Refactor error handling in Bumper, Media, Group, and Junction components to include contextual information Updated error definitions in BumperErrors, MediaErrors, GroupErrors, and TemplateErrors to accept a parameter for contextual information, enhancing the clarity of error messages. Refactored related command handlers to utilize these updated error messages, ensuring that users receive specific details about the usage context when attempting to delete resources. Adjusted unit tests to verify the correctness of the new error handling logic. --- .../Broadcast/Bumpers/BumperErrors.cs | 12 +++-- .../DeleteBumperTemplateCommandHandler.cs | 17 ++++++- .../Common/Models/UsageText.cs | 28 +++++++++++ .../Delete/DeleteMediaAssetCommandHandler.cs | 28 ++++++++++- .../TeleWave.Application/Media/MediaErrors.cs | 12 +++-- .../DeleteGroup/DeleteGroupCommandHandler.cs | 47 ++++++++++++++++++- .../Programming/Groups/GroupErrors.cs | 17 +++++-- .../Junctions/DeleteJunctionCommandHandler.cs | 36 +++++++++++++- .../Programming/Templates/TemplateErrors.cs | 12 +++-- .../Broadcast/BumperTemplateHandlersTests.cs | 2 +- .../Library/DeleteGuardsTests.cs | 39 +++++++++++++-- .../Programming/JunctionHandlersTests.cs | 4 +- 12 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 backend/src/TeleWave.Application/Common/Models/UsageText.cs diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs index 08994d7..1ce2e1a 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperErrors.cs @@ -20,10 +20,14 @@ public static class BumperErrors "Нельзя удалить последний подблок — нужен хотя бы один." ); - public static readonly Error TemplateInUse = Error.Conflict( - "Bumpers.TemplateInUse", - "Блок заставки используется во врезках стыков." - ); + /// Блок общий: держат его врезки конкретных стыков, их и перечисляем. + public static Error TemplateInUse(string where) => + Error.Conflict( + "Bumpers.TemplateInUse", + where.Length > 0 + ? $"Блок заставки используется во врезках стыков ({where}) — уберите его оттуда." + : "Блок заставки используется во врезках стыков." + ); public static readonly Error VariantInUse = Error.Conflict( "Bumpers.VariantInUse", diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs index 0e4feb0..35205cf 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/DeleteBumperTemplateCommandHandler.cs @@ -29,7 +29,22 @@ public sealed class DeleteBumperTemplateCommandHandler( cancellationToken ); if (used) - return Result.Failure(BumperErrors.TemplateInUse); + { + // Имя стыка — по связи врезки с ним; факт использования проверен отдельно и раньше. + var junctions = await ( + from element in dbContext.JunctionElements + join junction in dbContext.JunctionTemplates + on element.JunctionTemplateId equals junction.Id + where element.BumperTemplateId == template.Id + select junction.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + return Result.Failure( + BumperErrors.TemplateInUse(UsageText.Part("стыки", junctions) ?? string.Empty) + ); + } dbContext.BumperTemplates.Remove(template); await storage.DeleteAudioAsync(template.Id, cancellationToken); diff --git a/backend/src/TeleWave.Application/Common/Models/UsageText.cs b/backend/src/TeleWave.Application/Common/Models/UsageText.cs new file mode 100644 index 0000000..ee45d86 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Models/UsageText.cs @@ -0,0 +1,28 @@ +namespace TeleWave.Application.Common.Models; + +/// +/// Склейка ответа на вопрос «где это используется» для текста ошибки. Отказ вида «объект занят» +/// без адреса заставляет обходить все экраны подряд, а перечисление держателей превращает ошибку +/// в инструкцию: видно, что именно отвязать. +/// +public static class UsageText +{ + /// Сколько имён показываем: длинный список в тосте всё равно не читается. + private const int MaxNames = 5; + + /// Раздел перечисления: «слоты каналов: Первый, Второй». Пустой список — пропускается. + public static string? Part(string what, IReadOnlyCollection names) + { + if (names.Count == 0) + return null; + + var shown = string.Join(", ", names.Take(MaxNames)); + return names.Count > MaxNames + ? $"{what}: {shown} и ещё {names.Count - MaxNames}" + : $"{what}: {shown}"; + } + + /// Собирает разделы в одну строку; пустые отбрасываются. + public static string Join(params string?[] parts) => + string.Join("; ", parts.Where(p => !string.IsNullOrWhiteSpace(p))); +} diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs index 18b29fb..e3c5c4c 100644 --- a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs @@ -32,8 +32,11 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed c => c.FillerAssetId == asset.Id, cancellationToken ); + if (inSchedule || isFiller) - return Result.Failure(MediaErrors.InUse); + return Result.Failure( + MediaErrors.InUse(await UsedByAsync(asset.Id, cancellationToken)) + ); // Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске // безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты. @@ -45,4 +48,27 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed return Result.Success(); } + + /// Чем занят ассет: расписанием каких каналов и где он выбран заполнителем. + private async Task UsedByAsync(Guid assetId, CancellationToken cancellationToken) + { + var inSchedule = await ( + from entry in dbContext.ScheduleEntries + join channel in dbContext.Channels on entry.ChannelId equals channel.Id + where entry.MediaAssetId == assetId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var asFiller = await dbContext + .Channels.Where(c => c.FillerAssetId == assetId) + .Select(c => c.Name) + .ToListAsync(cancellationToken); + + return UsageText.Join( + UsageText.Part("расписание каналов", inSchedule), + UsageText.Part("заполнитель каналов", asFiller) + ); + } } diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs index 89e39a9..bbda962 100644 --- a/backend/src/TeleWave.Application/Media/MediaErrors.cs +++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs @@ -34,10 +34,14 @@ public static class MediaErrors "Исходный файл не найден в хранилище." ); - public static readonly Error InUse = Error.Conflict( - "Media.InUse", - "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира." - ); + /// Чем именно занят ассет: расписанием какого канала или ролью заполнителя. + public static Error InUse(string where) => + Error.Conflict( + "Media.InUse", + where.Length > 0 + ? $"Ассет занят ({where}) — удалить его нельзя." + : "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира." + ); 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 0810d0c..c118e90 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -34,10 +34,55 @@ public sealed class DeleteGroupCommandHandler(IAppDbContext dbContext) t => t.FallbackGroupId == group.Id, cancellationToken ); + + // Сам факт занятости и адрес — разные вопросы: адрес собирается по цепочке слой → шаблон → + // канал, и если она неполна, имени не будет, а запрещать удаление всё равно надо. if (used) - return Result.Failure(GroupErrors.InUse); + return Result.Failure( + GroupErrors.InUse(await UsedByAsync(group.Id, cancellationToken)) + ); dbContext.Groups.Remove(group); return Result.Success(); } + + /// Кто держит группу: слоты каналов, стыки и аварийная группа шаблона. + private async Task UsedByAsync(Guid groupId, CancellationToken cancellationToken) + { + var slots = await ( + from slot in dbContext.Slots + join layer in dbContext.GridLayers on slot.LayerId equals layer.Id + join template in dbContext.ScheduleTemplates on layer.TemplateId equals template.Id + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where slot.GroupId == groupId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var junctions = await ( + from element in dbContext.JunctionElements + join junction in dbContext.JunctionTemplates + on element.JunctionTemplateId equals junction.Id + where element.GroupId == groupId + select junction.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var fallback = await ( + from template in dbContext.ScheduleTemplates + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where template.FallbackGroupId == groupId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + return UsageText.Join( + UsageText.Part("слоты каналов", slots), + UsageText.Part("врезки стыков", junctions), + UsageText.Part("аварийная группа каналов", fallback) + ); + } } diff --git a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs index f248158..384b7f4 100644 --- a/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Groups/GroupErrors.cs @@ -16,10 +16,19 @@ public static class GroupErrors "Шоу или коллекция не найдены." ); - public static readonly Error InUse = Error.Conflict( - "Groups.InUse", - "Группа используется в сетке: на неё ссылается слот, врезка стыка или запасная группа шаблона." - ); + /// + /// Где именно занята группа: без адреса «используется в сетке» отправляет обходить все каналы. + /// Пустой — связи неполны (слой без шаблона, шаблон без канала), + /// и тогда честнее общий текст, чем пустые скобки. + /// + public static Error InUse(string where) => + Error.Conflict( + "Groups.InUse", + where.Length > 0 + ? $"Группа используется в сетке ({where}) — сначала отвяжите её там." + : "Группа используется в сетке: на неё ссылается слот, врезка стыка " + + "или запасная группа шаблона." + ); public static readonly Error FilterNotSet = Error.Validation( "Groups.FilterNotSet", diff --git a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs index d988061..4ce6ce0 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Junctions/DeleteJunctionCommandHandler.cs @@ -30,10 +30,44 @@ public sealed class DeleteJunctionCommandHandler(IAppDbContext dbContext) t => t.DefaultJunctionId == junction.Id, cancellationToken ); + + // Факт и адрес — разные вопросы: имя канала берётся по цепочке связей, и её неполнота + // не должна превращать запрет в разрешение. if (usedBySlot || usedByDefault) - return Result.Failure(TemplateErrors.JunctionInUse); + return Result.Failure( + TemplateErrors.JunctionInUse(await UsedByAsync(junction.Id, cancellationToken)) + ); dbContext.JunctionTemplates.Remove(junction); return Result.Success(); } + + /// Кто держит стык: слоты каналов и каналы, где он выбран стыком по умолчанию. + private async Task UsedByAsync(Guid junctionId, CancellationToken cancellationToken) + { + var slots = await ( + from slot in dbContext.Slots + join layer in dbContext.GridLayers on slot.LayerId equals layer.Id + join template in dbContext.ScheduleTemplates on layer.TemplateId equals template.Id + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where slot.JunctionBetweenId == junctionId || slot.JunctionAfterId == junctionId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + var byDefault = await ( + from template in dbContext.ScheduleTemplates + join channel in dbContext.Channels on template.ChannelId equals channel.Id + where template.DefaultJunctionId == junctionId + select channel.Name + ) + .Distinct() + .ToListAsync(cancellationToken); + + return UsageText.Join( + UsageText.Part("слоты каналов", slots), + UsageText.Part("стык по умолчанию у каналов", byDefault) + ); + } } diff --git a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs index 16ad3ac..ebf0b13 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/TemplateErrors.cs @@ -49,10 +49,14 @@ public static class TemplateErrors "Врезка не найдена." ); - public static readonly Error JunctionInUse = Error.Conflict( - "Templates.JunctionInUse", - "Стык используется слотами — сначала отвяжите его." - ); + /// Стык общий, поэтому держать его может слот чужого канала — называем какого. + public static Error JunctionInUse(string where) => + Error.Conflict( + "Templates.JunctionInUse", + where.Length > 0 + ? $"Стык используется ({where}) — сначала отвяжите его там." + : "Стык используется слотами — сначала отвяжите его." + ); public static readonly Error JunctionGroupRequired = Error.Validation( "Templates.JunctionGroupRequired", diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs index 71ac6fa..bbd7566 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperTemplateHandlersTests.cs @@ -259,7 +259,7 @@ public class BumperTemplateHandlersTests new DeleteBumperTemplateCommand(template.Id), CancellationToken.None ); - Assert.Equal(BumperErrors.TemplateInUse, used.Error); + Assert.Equal(BumperErrors.TemplateInUse(string.Empty).Code, used.Error.Code); } await using (var db = fixture.New()) diff --git a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs index d6dc7c8..a70f5a7 100644 --- a/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Library/DeleteGuardsTests.cs @@ -155,7 +155,7 @@ public class DeleteGuardsTests ); Assert.False(result.IsSuccess); - Assert.Equal(MediaErrors.InUse, result.Error); + Assert.Equal(MediaErrors.InUse(string.Empty).Code, result.Error.Code); // Файлы не тронуты: до чистки хранилища дело не дошло. await storage .DidNotReceiveWithAnyArgs() @@ -184,7 +184,7 @@ public class DeleteGuardsTests ).Handle(new DeleteMediaAssetCommand(asset.Id), CancellationToken.None); Assert.False(result.IsSuccess); - Assert.Equal(MediaErrors.InUse, result.Error); + Assert.Equal(MediaErrors.InUse(string.Empty).Code, result.Error.Code); } [Fact] @@ -222,7 +222,7 @@ public class DeleteGuardsTests ); Assert.False(result.IsSuccess); - Assert.Equal(GroupErrors.InUse, result.Error); + Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code); } /// Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно. @@ -248,7 +248,38 @@ public class DeleteGuardsTests ); Assert.False(result.IsSuccess); - Assert.Equal(GroupErrors.InUse, result.Error); + Assert.Equal(GroupErrors.InUse(string.Empty).Code, result.Error.Code); + } + + /// + /// Отказ должен называть адрес: «используется в сетке» без имени канала отправляет обходить + /// их все руками. Связи полные — значит и канал в тексте есть. + /// + [Fact] + public async Task DeleteGroup_UsedByChannel_NamesIt() + { + var fixture = new TestDb(); + var group = Group.Create("G"); + var channel = Channel.Create("Первый", $"first-{Guid.NewGuid():N}", T0); + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + template.SetFallbackGroup(group.Id); + + await using (var seed = fixture.New()) + { + seed.Groups.Add(group); + seed.Channels.Add(channel); + 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.Contains("Первый", result.Error.Message, StringComparison.Ordinal); } [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs index 617a99f..37ed9b5 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/JunctionHandlersTests.cs @@ -156,7 +156,7 @@ public class JunctionHandlersTests new DeleteJunctionCommand(junctionId), CancellationToken.None ); - Assert.Equal(TemplateErrors.JunctionInUse, used.Error); + Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, used.Error.Code); } // Снимаем ссылку слота, но оставляем стыком по умолчанию — удалять всё равно нельзя. @@ -189,7 +189,7 @@ public class JunctionHandlersTests new DeleteJunctionCommand(junctionId), CancellationToken.None ); - Assert.Equal(TemplateErrors.JunctionInUse, asDefault.Error); + Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, asDefault.Error.Code); } }