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.
This commit is contained in:
@@ -20,10 +20,14 @@ public static class BumperErrors
|
||||
"Нельзя удалить последний подблок — нужен хотя бы один."
|
||||
);
|
||||
|
||||
public static readonly Error TemplateInUse = Error.Conflict(
|
||||
"Bumpers.TemplateInUse",
|
||||
"Блок заставки используется во врезках стыков."
|
||||
);
|
||||
/// <summary>Блок общий: держат его врезки конкретных стыков, их и перечисляем.</summary>
|
||||
public static Error TemplateInUse(string where) =>
|
||||
Error.Conflict(
|
||||
"Bumpers.TemplateInUse",
|
||||
where.Length > 0
|
||||
? $"Блок заставки используется во врезках стыков ({where}) — уберите его оттуда."
|
||||
: "Блок заставки используется во врезках стыков."
|
||||
);
|
||||
|
||||
public static readonly Error VariantInUse = Error.Conflict(
|
||||
"Bumpers.VariantInUse",
|
||||
|
||||
+16
-1
@@ -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);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace TeleWave.Application.Common.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Склейка ответа на вопрос «где это используется» для текста ошибки. Отказ вида «объект занят»
|
||||
/// без адреса заставляет обходить все экраны подряд, а перечисление держателей превращает ошибку
|
||||
/// в инструкцию: видно, что именно отвязать.
|
||||
/// </summary>
|
||||
public static class UsageText
|
||||
{
|
||||
/// <summary>Сколько имён показываем: длинный список в тосте всё равно не читается.</summary>
|
||||
private const int MaxNames = 5;
|
||||
|
||||
/// <summary>Раздел перечисления: «слоты каналов: Первый, Второй». Пустой список — пропускается.</summary>
|
||||
public static string? Part(string what, IReadOnlyCollection<string> 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}";
|
||||
}
|
||||
|
||||
/// <summary>Собирает разделы в одну строку; пустые отбрасываются.</summary>
|
||||
public static string Join(params string?[] parts) =>
|
||||
string.Join("; ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Чем занят ассет: расписанием каких каналов и где он выбран заполнителем.</summary>
|
||||
private async Task<string> 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,14 @@ public static class MediaErrors
|
||||
"Исходный файл не найден в хранилище."
|
||||
);
|
||||
|
||||
public static readonly Error InUse = Error.Conflict(
|
||||
"Media.InUse",
|
||||
"Ассет занят: он стоит в расписании канала или выбран заполнителем эфира."
|
||||
);
|
||||
/// <summary>Чем именно занят ассет: расписанием какого канала или ролью заполнителя.</summary>
|
||||
public static Error InUse(string where) =>
|
||||
Error.Conflict(
|
||||
"Media.InUse",
|
||||
where.Length > 0
|
||||
? $"Ассет занят ({where}) — удалить его нельзя."
|
||||
: "Ассет занят: он стоит в расписании канала или выбран заполнителем эфира."
|
||||
);
|
||||
|
||||
public static readonly Error DuplicateFileName = Error.Conflict(
|
||||
"Media.DuplicateFileName",
|
||||
|
||||
+46
-1
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Кто держит группу: слоты каналов, стыки и аварийная группа шаблона.</summary>
|
||||
private async Task<string> 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,19 @@ public static class GroupErrors
|
||||
"Шоу или коллекция не найдены."
|
||||
);
|
||||
|
||||
public static readonly Error InUse = Error.Conflict(
|
||||
"Groups.InUse",
|
||||
"Группа используется в сетке: на неё ссылается слот, врезка стыка или запасная группа шаблона."
|
||||
);
|
||||
/// <summary>
|
||||
/// Где именно занята группа: без адреса «используется в сетке» отправляет обходить все каналы.
|
||||
/// Пустой <paramref name="where"/> — связи неполны (слой без шаблона, шаблон без канала),
|
||||
/// и тогда честнее общий текст, чем пустые скобки.
|
||||
/// </summary>
|
||||
public static Error InUse(string where) =>
|
||||
Error.Conflict(
|
||||
"Groups.InUse",
|
||||
where.Length > 0
|
||||
? $"Группа используется в сетке ({where}) — сначала отвяжите её там."
|
||||
: "Группа используется в сетке: на неё ссылается слот, врезка стыка "
|
||||
+ "или запасная группа шаблона."
|
||||
);
|
||||
|
||||
public static readonly Error FilterNotSet = Error.Validation(
|
||||
"Groups.FilterNotSet",
|
||||
|
||||
+35
-1
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Кто держит стык: слоты каналов и каналы, где он выбран стыком по умолчанию.</summary>
|
||||
private async Task<string> 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,10 +49,14 @@ public static class TemplateErrors
|
||||
"Врезка не найдена."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionInUse = Error.Conflict(
|
||||
"Templates.JunctionInUse",
|
||||
"Стык используется слотами — сначала отвяжите его."
|
||||
);
|
||||
/// <summary>Стык общий, поэтому держать его может слот чужого канала — называем какого.</summary>
|
||||
public static Error JunctionInUse(string where) =>
|
||||
Error.Conflict(
|
||||
"Templates.JunctionInUse",
|
||||
where.Length > 0
|
||||
? $"Стык используется ({where}) — сначала отвяжите его там."
|
||||
: "Стык используется слотами — сначала отвяжите его."
|
||||
);
|
||||
|
||||
public static readonly Error JunctionGroupRequired = Error.Validation(
|
||||
"Templates.JunctionGroupRequired",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Запасная группа шаблона внешнего ключа не имеет — её пришлось бы ловить отдельно.</summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отказ должен называть адрес: «используется в сетке» без имени канала отправляет обходить
|
||||
/// их все руками. Связи полные — значит и канал в тексте есть.
|
||||
/// </summary>
|
||||
[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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user