Add restore functionality for template management and enhance related components
ci / build-backend (push) Successful in 2m47s
ci / build-frontend (push) Successful in 1m8s
ci / tests (push) Successful in 2m28s
ci / sonar (push) Successful in 6m49s

Implemented a new endpoint for restoring templates to their last applied state, allowing users to revert changes made since the last application. Updated the ScheduleTemplate class to include a snapshot of the last applied state, enabling rollback capabilities. Enhanced the frontend with a restore button in the ChannelDetail component, providing users with a confirmation prompt before executing the restore action. Localization updates were made to support new UI strings in both English and Russian, improving the overall user experience in template management.
This commit is contained in:
Leonid Pershin
2026-07-27 08:43:53 +03:00
parent a302750de6
commit 0fd4e762f4
21 changed files with 2227 additions and 133 deletions
@@ -11,6 +11,7 @@ using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
@@ -44,6 +45,10 @@ public static class TemplateEndpoints
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
admin
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
.Produces<RestoreTemplateResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
@@ -137,6 +142,16 @@ public static class TemplateEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> RestoreTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate(
Guid channelId,
ISender sender,
@@ -161,7 +161,9 @@ public sealed class GridScheduleGenerator(
await SaveCursorsAsync(result.Cursors, cancellationToken);
template.MarkApplied();
// Снимок вместе с отметкой: применённое состояние — единственная корректная точка отката,
// и снимать его надо ровно тогда, когда по нему собрана лента.
template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
@@ -93,6 +93,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
template.AppliedSnapshotJson is not null,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
@@ -0,0 +1,18 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Restore;
/// <summary>
/// Откат правок сетки к тому состоянию, по которому идёт эфир. Возвращает шаблон к снимку,
/// сделанному при последнем применении, — правки слоёв, слотов и правил после него отменяются.
/// </summary>
public sealed record RestoreTemplateCommand(Guid ChannelId)
: ICommand<Result<RestoreTemplateResultDto>>;
/// <param name="Slots">Сколько слотов в сетке после отката.</param>
/// <param name="DroppedRefs">
/// Ссылок, которые восстановить не удалось: группа или стык из снимка успели удалить. Такие слоты
/// возвращаются без них — иначе откат уронил бы вся операцию из-за одной удалённой группы.
/// </param>
public sealed record RestoreTemplateResultDto(int Slots, int DroppedRefs);
@@ -0,0 +1,182 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Restore;
/// <summary>
/// Возвращает шаблон к снимку последнего применения. Слои и слоты правятся на месте по
/// идентификаторам из снимка: у пережившего откат слота сохраняется состояние (курсор сериала),
/// и после отката сериал не начинается заново. Слот, который успели удалить, вернётся новым —
/// его состояние ушло вместе с ним, и сделать с этим ничего нельзя.
///
/// Эфир не трогается: он и так идёт по этому состоянию, а шаблон после отката снова ему равен.
/// </summary>
public sealed class RestoreTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<RestoreTemplateCommand, Result<RestoreTemplateResultDto>>
{
public async Task<Result<RestoreTemplateResultDto>> Handle(
RestoreTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.ChannelId, cancellationToken);
if (template is null)
return Result.Failure<RestoreTemplateResultDto>(ChannelErrors.TemplateNotFound);
if (TemplateSnapshot.FromJson(template.AppliedSnapshotJson) is not { } snapshot)
return Result.Failure<RestoreTemplateResultDto>(TemplateErrors.NothingToRestore);
var refs = await LoadAliveRefsAsync(snapshot, cancellationToken);
// Слои, которых в снимке нет, — заведены после применения. Фоновый не трогаем: он есть
// в любом шаблоне и удалению не подлежит.
foreach (var layer in template.Layers.ToList())
if (!layer.IsBackground && snapshot.Layers.All(l => l.Id != layer.Id))
template.RemoveLayer(layer.Id);
foreach (var layerSnapshot in snapshot.Layers)
RestoreLayer(template, layerSnapshot, refs);
template.Rename(snapshot.Name);
template.SetFallbackGroup(refs.GroupOrNull(snapshot.FallbackGroupId));
template.SetDefaultJunction(refs.JunctionOrNull(snapshot.DefaultJunctionId));
template.SetRules(snapshot.RulesJson);
// Шаблон снова равен эфирному состоянию — отметка «изменено» гаснет, снимок остаётся тем же.
template.MarkApplied();
return Result.Success(
new RestoreTemplateResultDto(template.Layers.Sum(l => l.Slots.Count), refs.Dropped)
);
}
private static void RestoreLayer(
ScheduleTemplate template,
LayerSnapshot snapshot,
AliveRefs refs
)
{
var layer =
template.FindLayer(snapshot.Id)
?? (snapshot.IsBackground ? template.Background : null)
?? template.AddLayer(snapshot.Name, snapshot.Priority);
layer.Update(
snapshot.Name,
snapshot.Priority,
snapshot.ApplicabilityJson,
snapshot.IsEnabled
);
foreach (var slot in layer.Slots.ToList())
if (snapshot.Slots.All(s => s.Id != slot.Id))
layer.RemoveSlot(slot.Id);
foreach (var slotSnapshot in snapshot.Slots)
RestoreSlot(layer, slotSnapshot, refs);
}
private static void RestoreSlot(GridLayer layer, SlotSnapshot snapshot, AliveRefs refs)
{
var slot =
layer.FindSlot(snapshot.Id)
?? layer.AddSlot(
snapshot.Title,
snapshot.TargetStart,
snapshot.TargetDurationMinutes,
snapshot.Daypart,
snapshot.SlotKind,
snapshot.Weekday
);
slot.UpdateTiming(
snapshot.Weekday,
snapshot.TargetStart,
snapshot.TargetDurationMinutes,
snapshot.Daypart,
snapshot.IsAnchor,
snapshot.MaxDriftMinutes,
snapshot.SnapToMinutes
);
slot.UpdateContent(
new SlotContent(
snapshot.Title,
snapshot.SlotKind,
refs.GroupOrNull(snapshot.GroupId),
snapshot.StrategyJson,
snapshot.RepeatSourceJson,
snapshot.BlockMode,
snapshot.BlockValue,
snapshot.OverflowPolicy,
refs.JunctionOrNull(snapshot.JunctionBetweenId),
refs.JunctionOrNull(snapshot.JunctionAfterId)
)
);
}
/// <summary>
/// Какие группы и стыки из снимка ещё существуют. Ссылку на удалённое восстанавливать нельзя —
/// внешний ключ не даст сохранить, и откат упал бы целиком из-за одной прибранной группы.
/// </summary>
private async Task<AliveRefs> LoadAliveRefsAsync(
TemplateSnapshot snapshot,
CancellationToken cancellationToken
)
{
var slots = snapshot.Layers.SelectMany(l => l.Slots).ToList();
var groupIds = slots
.Select(s => s.GroupId)
.Append(snapshot.FallbackGroupId)
.OfType<Guid>()
.Distinct()
.ToList();
var junctionIds = slots
.SelectMany(s => new[] { s.JunctionBetweenId, s.JunctionAfterId })
.Append(snapshot.DefaultJunctionId)
.OfType<Guid>()
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => g.Id)
.ToListAsync(cancellationToken);
var junctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => junctionIds.Contains(j.Id))
.Select(j => j.Id)
.ToListAsync(cancellationToken);
return new AliveRefs([.. groups], [.. junctions]);
}
private sealed class AliveRefs(HashSet<Guid> groups, HashSet<Guid> junctions)
{
public int Dropped { get; private set; }
public Guid? GroupOrNull(Guid? id) => Keep(id, groups);
public Guid? JunctionOrNull(Guid? id) => Keep(id, junctions);
private Guid? Keep(Guid? id, HashSet<Guid> alive)
{
if (id is not { } value)
return null;
if (alive.Contains(value))
return value;
Dropped++;
return null;
}
}
}
@@ -47,6 +47,8 @@ public sealed record ScheduleTemplateDto(
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Есть ли к чему откатываться — снимок появляется при первом применении.</summary>
bool CanRestore,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
@@ -59,6 +59,11 @@ public static class TemplateErrors
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error NothingToRestore = Error.Validation(
"Templates.NothingToRestore",
"Откатывать не к чему: сетка ещё ни разу не применялась."
);
public static readonly Error NoGroupsToGenerate = Error.Validation(
"Templates.NoGroupsToGenerate",
"Собирать сетку не из чего: нет ни одной группы с готовым медиа."
@@ -0,0 +1,119 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Слот в снимке. Идентификатор сохраняется — по нему откат правит слот на месте.</summary>
public sealed record SlotSnapshot(
Guid Id,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? StrategyJson,
string? RepeatSourceJson,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record LayerSnapshot(
Guid Id,
string Name,
int Priority,
string? ApplicabilityJson,
bool IsEnabled,
bool IsBackground,
IReadOnlyList<SlotSnapshot> Slots
);
/// <summary>
/// Слепок шаблона: слои, слоты и настройки на момент применения. Снимается при каждом применении —
/// это ровно то, по чему идёт эфир, и потому корректная точка отката.
///
/// Хранится JSON-строкой в самом шаблоне, а не отдельной таблицей: снимок читается только целиком
/// и только при откате, и своя таблица со слоями и слотами задвоила бы всю схему сетки — включая
/// внешние ключи на группы, которые к моменту отката могут быть уже удалены.
/// </summary>
public sealed record TemplateSnapshot(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
string? RulesJson,
IReadOnlyList<LayerSnapshot> Layers
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
public static TemplateSnapshot Capture(ScheduleTemplate template) =>
new(
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
template.RulesJson,
[
.. template.Layers.Select(layer => new LayerSnapshot(
layer.Id,
layer.Name,
layer.Priority,
layer.ApplicabilityJson,
layer.IsEnabled,
layer.IsBackground,
[
.. layer.Slots.Select(slot => new SlotSnapshot(
slot.Id,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
)),
]
)),
]
);
public string ToJson() => JsonSerializer.Serialize(this, Options);
/// <summary>Разбирает снимок. Повреждённый JSON — как отсутствие снимка: откатывать не к чему.</summary>
public static TemplateSnapshot? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<TemplateSnapshot>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
@@ -34,6 +34,14 @@ public class ScheduleTemplate
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
public int AppliedRevision { get; private set; }
/// <summary>
/// Состояние шаблона на момент последнего применения (JSON) — то, по чему идёт эфир прямо
/// сейчас. Нужно, чтобы правки можно было откатить: слои и слоты правятся на месте, и без
/// снимка «как было» взять неоткуда. Домен его не разбирает — схема живёт в Application, как
/// у правил и стратегий.
/// </summary>
public string? AppliedSnapshotJson { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<GridLayer> Layers => _layers;
@@ -80,8 +88,16 @@ public class ScheduleTemplate
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++;
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
public void MarkApplied() => AppliedRevision = Revision;
/// <summary>
/// Отметить, что хвост пересобран по текущей ревизии. Снимок — состояние, по которому собран
/// эфир; он же станет точкой отката для последующих правок.
/// </summary>
public void MarkApplied(string? snapshotJson = null)
{
AppliedRevision = Revision;
if (!string.IsNullOrWhiteSpace(snapshotJson))
AppliedSnapshotJson = snapshotJson;
}
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TemplateAppliedSnapshot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AppliedSnapshotJson",
table: "ScheduleTemplates",
type: "jsonb",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "AppliedSnapshotJson", table: "ScheduleTemplates");
}
}
}
@@ -944,6 +944,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("AppliedRevision")
.HasColumnType("integer");
b.Property<string>("AppliedSnapshotJson")
.HasColumnType("jsonb");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
@@ -11,6 +11,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration<ScheduleTe
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.RulesJson).HasColumnType("jsonb");
builder.Property(x => x.AppliedSnapshotJson).HasColumnType("jsonb");
builder.HasIndex(x => x.ChannelId);
builder
@@ -0,0 +1,220 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Откат правок сетки к применённому состоянию: что вернулось, что удалилось и что при этом
/// сохранило свой идентификатор — от него зависит, переживёт ли откат курсор сериала.
/// </summary>
public class RestoreTemplateTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
/// <summary>Канал с применённой сеткой: один слот и снятый по нему снимок.</summary>
private static async Task<(Guid ChannelId, Guid SlotId)> SeedAppliedAsync(
TestDb fixture,
Guid? groupId = null
)
{
var channel = Channel.Create("Первый", "first", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
channel.SetTemplate(template.Id);
var layer = template.AddLayer("Основная сетка", 10);
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
slot.UpdateContent(
new SlotContent(
"Вечернее кино",
SlotKind.Content,
groupId,
null,
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
// Применение снимает слепок — именно к нему потом откатываемся.
template.MarkApplied(TemplateSnapshot.Capture(template).ToJson());
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return (channel.Id, slot.Id);
}
private static async Task<ScheduleTemplate> LoadAsync(TestDb fixture)
{
await using var db = fixture.New();
return await db
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstAsync(CancellationToken.None);
}
private static async Task<RestoreTemplateResultDto> RestoreAsync(TestDb fixture, Guid channelId)
{
await using var db = fixture.New();
var result = await new RestoreTemplateCommandHandler(db).Handle(
new RestoreTemplateCommand(channelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return result.Value;
}
[Fact]
public async Task RevertsEditedSlot_KeepingItsIdentity()
{
var fixture = new TestDb();
var (channelId, slotId) = await SeedAppliedAsync(fixture);
await using (var db = fixture.New())
{
var template = await db
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstAsync(CancellationToken.None);
var slot = template.Layers.SelectMany(l => l.Slots).Single();
slot.UpdateTiming(3, new TimeOnly(8, 0), 30, Daypart.Morning, false, 5, null);
template.MarkChanged();
await db.SaveChangesAsync(CancellationToken.None);
}
var result = await RestoreAsync(fixture, channelId);
Assert.Equal(1, result.Slots);
var restored = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single();
Assert.Equal(new TimeOnly(20, 0), restored.TargetStart);
Assert.Equal(120, restored.TargetDurationMinutes);
Assert.Null(restored.Weekday);
// Тот же слот, а не копия: с его идентификатором связано состояние (курсор сериала).
Assert.Equal(slotId, restored.Id);
}
[Fact]
public async Task RemovesWhatAppearedAfterApply_AndBringsBackWhatWasDeleted()
{
var fixture = new TestDb();
var (channelId, slotId) = await SeedAppliedAsync(fixture);
await using (var db = fixture.New())
{
var template = await db
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstAsync(CancellationToken.None);
var layer = template.Layers.Single(l => !l.IsBackground);
layer.RemoveSlot(slotId);
layer.AddSlot("Лишний", new TimeOnly(10, 0), 60);
template.AddLayer("Лишний слой", 20);
template.MarkChanged();
await db.SaveChangesAsync(CancellationToken.None);
}
await RestoreAsync(fixture, channelId);
var restored = await LoadAsync(fixture);
var slot = Assert.Single(restored.Layers.SelectMany(l => l.Slots));
Assert.Equal("Вечернее кино", slot.Title);
Assert.DoesNotContain(restored.Layers, l => l.Name == "Лишний слой");
// Удалённый слот возвращается новым: его состояние ушло вместе с ним, вернуть нечего.
Assert.NotEqual(slotId, slot.Id);
}
[Fact]
public async Task ClearsPendingFlag()
{
var fixture = new TestDb();
var (channelId, _) = await SeedAppliedAsync(fixture);
await using (var db = fixture.New())
{
var template = await db.ScheduleTemplates.FirstAsync(CancellationToken.None);
template.SetRules("{\"maxBreakMinutesPerHour\":10}");
template.MarkChanged();
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(template.HasPendingChanges);
}
await RestoreAsync(fixture, channelId);
var restored = await LoadAsync(fixture);
Assert.False(restored.HasPendingChanges);
Assert.Null(restored.RulesJson);
}
[Fact]
public async Task DropsReferenceToGroupDeletedAfterApply()
{
var fixture = new TestDb();
var group = Group.Create("Кино");
Guid channelId;
await using (var seed = fixture.New())
{
seed.Groups.Add(group);
await seed.SaveChangesAsync(CancellationToken.None);
}
(channelId, _) = await SeedAppliedAsync(fixture, group.Id);
// Слот удалили, следом удалили и группу — ссылка в снимке осталась висеть.
await using (var db = fixture.New())
{
var template = await db
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.FirstAsync(CancellationToken.None);
var layer = template.Layers.Single(l => !l.IsBackground);
layer.RemoveSlot(layer.Slots[0].Id);
db.Groups.Remove(await db.Groups.FirstAsync(CancellationToken.None));
template.MarkChanged();
await db.SaveChangesAsync(CancellationToken.None);
}
var result = await RestoreAsync(fixture, channelId);
// Откат не падает из-за прибранной группы: слот возвращается без неё, а потеря сосчитана.
Assert.Equal(1, result.DroppedRefs);
var slot = (await LoadAsync(fixture)).Layers.SelectMany(l => l.Slots).Single();
Assert.Null(slot.GroupId);
}
[Fact]
public async Task Fails_WhenTemplateWasNeverApplied()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "first", T0);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
channel.SetTemplate(template.Id);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new RestoreTemplateCommandHandler(db).Handle(
new RestoreTemplateCommand(channel.Id),
CancellationToken.None
);
Assert.False(result.IsSuccess);
Assert.Equal(TemplateErrors.NothingToRestore, result.Error);
}
}