Files
TeleWave/backend/tests/TeleWave.Application.Tests/Programming/TemplateEditingTests.cs
T
Leonid Pershin 790d01b587
ci / build-backend (push) Successful in 2m34s
ci / build-frontend (push) Successful in 41s
ci / tests (push) Successful in 2m55s
ci / sonar (push) Successful in 5m21s
Update README.md with additional SonarCloud badges and improve CI workflow for coverage reporting
Enhanced the README.md file by adding new SonarCloud badges for coverage, bugs, code smells, security rating, and maintainability rating. Updated the CI workflow to remove coverage collection from the test step, as it is now handled by SonarCloud, streamlining the process and ensuring accurate badge representation.
2026-07-27 03:19:18 +03:00

533 lines
18 KiB
C#

using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Правка сетки: слои, слоты и настройки шаблона. Проверки здесь те, что валидатору не по силам —
/// им нужны соседние слоты и справочник групп, — плюс инвариант «любая правка двигает ревизию».
/// </summary>
public class TemplateEditingTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static SlotInput Input(
string title = "Вечернее кино",
TimeOnly? start = null,
int minutes = 120,
SlotKind kind = SlotKind.Content,
Guid? groupId = null,
RepeatSource? repeat = null,
int? weekday = null
) =>
new(
title,
weekday,
start ?? new TimeOnly(20, 0),
minutes,
Daypart.Prime,
kind,
groupId,
new SlotStrategy(SlotStrategyType.Sequential),
repeat,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
IsAnchor: true,
MaxDriftMinutes: 10,
SnapToMinutes: 15
);
/// <summary>Канал с сеткой: один обычный слой поверх фонового плюс группа контента.</summary>
private sealed record Fixture(
TestDb Db,
Guid ChannelId,
Guid TemplateId,
Guid LayerId,
Guid BackgroundLayerId,
Guid GroupId
);
private static async Task<Fixture> SeedAsync()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Кино");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Прайм", 10);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
return new Fixture(
fixture,
channel.Id,
template.Id,
layer.Id,
template.Background!.Id,
group.Id
);
}
private static async Task<Guid> AddSlotAsync(Fixture f, SlotInput input)
{
await using var db = f.Db.New();
var created = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, input),
CancellationToken.None
);
Assert.True(created.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return created.Value;
}
private static async Task<Slot> LoadSlotAsync(Fixture f, Guid slotId)
{
await using var db = f.Db.New();
return await db.Slots.AsNoTracking().FirstAsync(s => s.Id == slotId);
}
private static async Task<int> RevisionAsync(Fixture f)
{
await using var db = f.Db.New();
return (
await db.ScheduleTemplates.AsNoTracking().FirstAsync(t => t.Id == f.TemplateId)
).Revision;
}
[Fact]
public async Task CreateSlot_UnknownLayer_ReturnsLayerNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, result.Error);
}
[Fact]
public async Task CreateSlot_StoresTimingAndContent_AndBumpsRevision()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId, weekday: 3));
var slot = await LoadSlotAsync(f, slotId);
Assert.Equal("Вечернее кино", slot.Title);
Assert.Equal(3, slot.Weekday);
Assert.Equal(new TimeOnly(20, 0), slot.TargetStart);
Assert.Equal(120, slot.TargetDurationMinutes);
Assert.Equal(Daypart.Prime, slot.Daypart);
Assert.Equal(f.GroupId, slot.GroupId);
Assert.True(slot.IsAnchor);
Assert.Equal(15, slot.SnapToMinutes);
Assert.NotNull(SlotStrategy.FromJson(slot.StrategyJson));
// Правка сетки эфир не двигает — только помечает шаблон изменённым.
Assert.Equal(1, await RevisionAsync(f));
}
[Fact]
public async Task CreateSlot_ContentWithoutGroup_ReturnsGroupRequired()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: null)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupRequired, result.Error);
}
[Fact]
public async Task CreateSlot_UnknownGroup_ReturnsGroupNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(groupId: Guid.NewGuid())),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
}
[Fact]
public async Task CreateSlot_RepeatWithoutSource_ReturnsRepeatSourceRequired()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(f.LayerId, Input(kind: SlotKind.Repeat)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.RepeatSourceRequired, result.Error);
}
[Fact]
public async Task CreateSlot_Repeat_KeepsSourceAndDropsGroup()
{
var f = await SeedAsync();
var repeat = new RepeatSource(1, new TimeOnly(20, 0), 120);
var slotId = await AddSlotAsync(
f,
Input(kind: SlotKind.Repeat, groupId: f.GroupId, repeat: repeat)
);
var slot = await LoadSlotAsync(f, slotId);
// Группа к повтору отношения не имеет: домен гасит поля чужого типа слота.
Assert.Null(slot.GroupId);
Assert.Equal(repeat, RepeatSource.FromJson(slot.RepeatSourceJson));
}
[Fact]
public async Task CreateSlot_OverlappingInSameLayer_ReturnsSlotsOverlap()
{
var f = await SeedAsync();
await AddSlotAsync(f, Input(groupId: f.GroupId));
await using var db = f.Db.New();
var result = await new CreateSlotCommandHandler(new SlotWriter(db)).Handle(
new CreateSlotCommand(
f.LayerId,
Input(title: "Второй", start: new TimeOnly(21, 0), groupId: f.GroupId)
),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotsOverlap, result.Error);
}
[Fact]
public async Task UpdateSlot_UnknownSlot_ReturnsSlotNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
new UpdateSlotCommand(Guid.NewGuid(), Input(groupId: f.GroupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotNotFound, result.Error);
}
[Fact]
public async Task UpdateSlot_MovesSlot_WithoutCountingItAsOverlap()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using (var db = f.Db.New())
{
// Сдвиг внутрь собственного интервала: слот не должен пересечься сам с собой.
var result = await new UpdateSlotCommandHandler(new SlotWriter(db)).Handle(
new UpdateSlotCommand(
slotId,
Input(title: "Ночное кино", start: new TimeOnly(21, 0), groupId: f.GroupId)
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
var slot = await LoadSlotAsync(f, slotId);
Assert.Equal("Ночное кино", slot.Title);
Assert.Equal(new TimeOnly(21, 0), slot.TargetStart);
Assert.Equal(2, await RevisionAsync(f));
}
[Fact]
public async Task DeleteSlot_RemovesSlot_OrReportsNotFound()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using (var db = f.Db.New())
{
var missing = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
new DeleteSlotCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.SlotNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var deleted = await new DeleteSlotCommandHandler(new SlotWriter(db)).Handle(
new DeleteSlotCommand(slotId),
CancellationToken.None
);
Assert.True(deleted.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
Assert.False(await verify.Slots.AnyAsync(s => s.Id == slotId));
}
[Fact]
public async Task CreateLayer_UnknownTemplate_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new CreateLayerCommandHandler(db).Handle(
new CreateLayerCommand(Guid.NewGuid(), "Лето", 20),
CancellationToken.None
);
Assert.Equal(TemplateErrors.NotFound, result.Error);
}
[Fact]
public async Task CreateLayer_AddsLayerToTemplate()
{
var f = await SeedAsync();
Guid layerId;
await using (var db = f.Db.New())
{
var result = await new CreateLayerCommandHandler(db).Handle(
new CreateLayerCommand(f.TemplateId, "Лето", 20),
CancellationToken.None
);
Assert.True(result.IsSuccess);
layerId = result.Value;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == layerId);
Assert.Equal("Лето", layer.Name);
Assert.Equal(20, layer.Priority);
Assert.False(layer.IsBackground);
}
[Fact]
public async Task UpdateLayer_StoresApplicability_OrReportsNotFound()
{
var f = await SeedAsync();
var applicability = new LayerApplicability(Weekdays: [5, 6]);
await using (var db = f.Db.New())
{
var missing = await new UpdateLayerCommandHandler(db).Handle(
new UpdateLayerCommand(Guid.NewGuid(), "Нет", 5, null, true),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var result = await new UpdateLayerCommandHandler(db).Handle(
new UpdateLayerCommand(f.LayerId, "Выходные", 30, applicability, false),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var layer = await verify.GridLayers.AsNoTracking().FirstAsync(l => l.Id == f.LayerId);
Assert.Equal("Выходные", layer.Name);
Assert.Equal(30, layer.Priority);
Assert.False(layer.IsEnabled);
var stored = LayerApplicability.FromJson(layer.ApplicabilityJson);
Assert.Equal(new[] { 5, 6 }, stored?.Weekdays);
}
[Fact]
public async Task DeleteLayer_RefusesBackground_AndRemovesOrdinary()
{
var f = await SeedAsync();
await using (var db = f.Db.New())
{
var background = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(f.BackgroundLayerId),
CancellationToken.None
);
// Без фонового слоя первую же дыру в сетке нечем закрыть.
Assert.Equal(TemplateErrors.BackgroundLayerCannotBeDeleted, background.Error);
}
await using (var db = f.Db.New())
{
var missing = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.LayerNotFound, missing.Error);
}
await using (var db = f.Db.New())
{
var deleted = await new DeleteLayerCommandHandler(db).Handle(
new DeleteLayerCommand(f.LayerId),
CancellationToken.None
);
Assert.True(deleted.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
Assert.False(await verify.GridLayers.AnyAsync(l => l.Id == f.LayerId));
}
[Fact]
public async Task UpdateTemplate_UnknownTemplate_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(Guid.NewGuid(), "Сетка", null, null, null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.NotFound, result.Error);
}
[Fact]
public async Task UpdateTemplate_UnknownFallbackGroup_ReturnsGroupNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(f.TemplateId, "Сетка", Guid.NewGuid(), null, null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, result.Error);
}
[Fact]
public async Task UpdateTemplate_StoresFallbackAndRules()
{
var f = await SeedAsync();
var rules = new PlanningRules(
MaxAudienceByTime:
[
new AudienceWindow(
new TimeOnly(6, 0),
new TimeOnly(21, 0),
Domain.Library.ShowAudience.Pg
),
],
MaxRepeatsInWindow: new RepeatLimitRule(7, 2)
);
await using (var db = f.Db.New())
{
var result = await new UpdateTemplateCommandHandler(db).Handle(
new UpdateTemplateCommand(f.TemplateId, " Новая сетка ", f.GroupId, null, rules),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = f.Db.New();
var template = await verify
.ScheduleTemplates.AsNoTracking()
.FirstAsync(t => t.Id == f.TemplateId);
Assert.Equal("Новая сетка", template.Name);
Assert.Equal(f.GroupId, template.FallbackGroupId);
var storedRules = PlanningRules.FromJson(template.RulesJson);
Assert.Equal(rules.MaxRepeatsInWindow, storedRules?.MaxRepeatsInWindow);
Assert.Equal(rules.MaxAudienceByTime, storedRules?.MaxAudienceByTime);
Assert.True(template.HasPendingChanges);
}
[Fact]
public async Task GetChannelTemplate_UnknownChannel_ReturnsNotFound()
{
var f = await SeedAsync();
await using var db = f.Db.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.Equal(ChannelErrors.NotFound, result.Error);
}
[Fact]
public async Task GetChannelTemplate_ChannelWithoutGrid_ReturnsTemplateNotFound()
{
var fixture = new TestDb();
var channel = Channel.Create("Без сетки", "nogrid", T0);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(channel.Id),
CancellationToken.None
);
Assert.Equal(ChannelErrors.TemplateNotFound, result.Error);
}
[Fact]
public async Task GetChannelTemplate_OrdersLayersByPriority_AndResolvesGroupNames()
{
var f = await SeedAsync();
var slotId = await AddSlotAsync(f, Input(groupId: f.GroupId));
await using var db = f.Db.New();
var result = await new GetChannelTemplateQueryHandler(db).Handle(
new GetChannelTemplateQuery(f.ChannelId),
CancellationToken.None
);
Assert.True(result.IsSuccess);
var dto = result.Value;
Assert.Equal(f.TemplateId, dto.Id);
Assert.True(dto.HasPendingChanges);
// Приоритетный слой идёт первым, фоновый — последним.
Assert.Equal(
new[] { f.LayerId, f.BackgroundLayerId },
dto.Layers.Select(l => l.Id).ToList()
);
Assert.True(dto.Layers[^1].IsBackground);
var slot = Assert.Single(dto.Layers[0].Slots);
Assert.Equal(slotId, slot.Id);
Assert.Equal("Кино", slot.GroupName);
Assert.Equal(SlotStrategyType.Sequential, slot.Strategy?.Type);
}
}