using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Junctions;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
///
/// Команды стыка. Стык общий для всех каналов, поэтому проверки здесь про две вещи: источник врезки
/// должен существовать, а правка — помечать изменёнными все шаблоны, которые на этот стык ссылаются.
///
public class JunctionHandlersTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static JunctionElementInput Input(
JunctionElementKind kind = JunctionElementKind.Ad,
Guid? groupId = null,
Guid? bumperTemplateId = null,
Guid? bumperVariantId = null,
int amountValue = 2,
bool isRequired = false,
string? choiceKey = null,
int choiceWeight = 1,
JunctionConditions? conditions = null
) =>
new(
kind,
groupId,
bumperTemplateId,
bumperVariantId,
JunctionAmountMode.Count,
amountValue,
isRequired,
choiceKey,
choiceWeight,
conditions
);
/// Канал со своей сеткой и слотом, который ссылается на стык, — минимум для проверки отметок.
private static async Task<(
TestDb Fixture,
Guid JunctionId,
Guid TemplateId,
Guid GroupId
)> SeedAsync()
{
var fixture = new TestDb();
var channel = Channel.Create("Первый", "one", T0);
var group = Group.Create("Реклама");
var junction = JunctionTemplate.Create("Прайм");
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var slot = template.Background!.AddSlot("Круглосуточно", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
null,
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
null,
junction.Id
)
);
channel.SetTemplate(template.Id);
await using var seed = fixture.New();
seed.Channels.Add(channel);
seed.Groups.Add(group);
seed.JunctionTemplates.Add(junction);
seed.ScheduleTemplates.Add(template);
await seed.SaveChangesAsync(CancellationToken.None);
// Шаблон создаётся уже «изменённым» — снимаем отметку, иначе проверять нечего.
template.MarkApplied("{}");
await seed.SaveChangesAsync(CancellationToken.None);
return (fixture, junction.Id, template.Id, group.Id);
}
private static Task HasPendingChangesAsync(AppDbContext db, Guid templateId) =>
db
.ScheduleTemplates.AsNoTracking()
.Where(t => t.Id == templateId)
.Select(t => t.Revision != t.AppliedRevision)
.FirstAsync();
[Fact]
public async Task Create_AddsGlobalJunction()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new CreateJunctionCommandHandler(db).Handle(
new CreateJunctionCommand(" Ночь "),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
await using var verify = fixture.New();
var junction = await verify.JunctionTemplates.SingleAsync();
Assert.Equal("Ночь", junction.Name);
}
[Fact]
public async Task Update_RenamesAndMarksReferencingTemplates()
{
var (fixture, junctionId, templateId, _) = await SeedAsync();
await using var db = fixture.New();
var result = await new UpdateJunctionCommandHandler(db).Handle(
new UpdateJunctionCommand(junctionId, "Ночь", 180),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
await using var verify = fixture.New();
var junction = await verify.JunctionTemplates.AsNoTracking().SingleAsync();
Assert.Equal("Ночь", junction.Name);
Assert.Equal(180, junction.MaxTotalSeconds);
// Стык общий: чужой канал не должен молча поехать по новым врезкам без применения.
Assert.True(await HasPendingChangesAsync(verify, templateId));
}
[Fact]
public async Task Update_UnknownJunction_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new UpdateJunctionCommandHandler(db).Handle(
new UpdateJunctionCommand(Guid.NewGuid(), "Ночь", null),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionNotFound, result.Error);
}
[Fact]
public async Task Delete_RefusesWhenUsedBySlotOrDefault()
{
var (fixture, junctionId, templateId, _) = await SeedAsync();
await using (var db = fixture.New())
{
var used = await new DeleteJunctionCommandHandler(db).Handle(
new DeleteJunctionCommand(junctionId),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, used.Error.Code);
}
// Снимаем ссылку слота, но оставляем стыком по умолчанию — удалять всё равно нельзя.
await using (var db = fixture.New())
{
var template = await db
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.SingleAsync(t => t.Id == templateId);
var slot = template.Layers.SelectMany(l => l.Slots).Single();
slot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
null,
null,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy
)
);
template.SetDefaultJunction(junctionId);
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var db = fixture.New())
{
var asDefault = await new DeleteJunctionCommandHandler(db).Handle(
new DeleteJunctionCommand(junctionId),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionInUse(string.Empty).Code, asDefault.Error.Code);
}
}
[Fact]
public async Task Delete_RemovesUnusedJunction()
{
var fixture = new TestDb();
var junction = JunctionTemplate.Create("Ночь");
await using (var seed = fixture.New())
{
seed.JunctionTemplates.Add(junction);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new DeleteJunctionCommandHandler(db).Handle(
new DeleteJunctionCommand(junction.Id),
CancellationToken.None
);
await db.SaveChangesAsync(CancellationToken.None);
Assert.True(result.IsSuccess);
await using var verify = fixture.New();
Assert.Empty(verify.JunctionTemplates);
}
[Fact]
public async Task AddAndRemoveElement_TouchReferencingTemplates()
{
var (fixture, junctionId, templateId, _) = await SeedAsync();
Guid elementId;
await using (var db = fixture.New())
{
var added = await new AddJunctionElementCommandHandler(db).Handle(
new AddJunctionElementCommand(junctionId, JunctionElementKind.Ad),
CancellationToken.None
);
Assert.True(added.IsSuccess);
elementId = added.Value;
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var verify = fixture.New())
Assert.True(await HasPendingChangesAsync(verify, templateId));
await using (var db = fixture.New())
{
var removed = await new RemoveJunctionElementCommandHandler(db).Handle(
new RemoveJunctionElementCommand(junctionId, elementId),
CancellationToken.None
);
Assert.True(removed.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var db = fixture.New())
{
var missing = await new RemoveJunctionElementCommandHandler(db).Handle(
new RemoveJunctionElementCommand(junctionId, elementId),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionElementNotFound, missing.Error);
}
}
[Fact]
public async Task UpdateElement_RequiresExistingSource()
{
var (fixture, junctionId, _, groupId) = await SeedAsync();
var bumper = BumperTemplate.Create("Блок", "Текст 1");
Guid elementId;
await using (var db = fixture.New())
{
db.BumperTemplates.Add(bumper);
var junction = await db
.JunctionTemplates.Include(j => j.Elements)
.SingleAsync(j => j.Id == junctionId);
elementId = junction.AddElement(JunctionElementKind.Ad).Id;
await db.SaveChangesAsync(CancellationToken.None);
}
await using var context = fixture.New();
var handler = new UpdateJunctionElementCommandHandler(context);
var noGroup = await handler.Handle(
new UpdateJunctionElementCommand(junctionId, elementId, Input()),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionGroupRequired, noGroup.Error);
var unknownGroup = await handler.Handle(
new UpdateJunctionElementCommand(junctionId, elementId, Input(groupId: Guid.NewGuid())),
CancellationToken.None
);
Assert.Equal(TemplateErrors.GroupNotFound, unknownGroup.Error);
var noBumper = await handler.Handle(
new UpdateJunctionElementCommand(
junctionId,
elementId,
Input(kind: JunctionElementKind.Bumper)
),
CancellationToken.None
);
Assert.Equal(BumperErrors.TemplateNotFound, noBumper.Error);
var alienVariant = await handler.Handle(
new UpdateJunctionElementCommand(
junctionId,
elementId,
Input(
kind: JunctionElementKind.Bumper,
bumperTemplateId: bumper.Id,
bumperVariantId: Guid.NewGuid()
)
),
CancellationToken.None
);
Assert.Equal(BumperErrors.VariantNotFound, alienVariant.Error);
var ok = await handler.Handle(
new UpdateJunctionElementCommand(
junctionId,
elementId,
Input(
groupId: groupId,
isRequired: true,
conditions: new JunctionConditions(true, 30, [Daypart.Prime], null, 40)
)
),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
await context.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var element = await verify.JunctionElements.AsNoTracking().SingleAsync();
var conditions = JunctionConditions.FromJson(element.ConditionsJson);
Assert.Equal(groupId, element.GroupId);
Assert.True(element.IsRequired);
Assert.Equal(40, conditions!.Chance);
Assert.Equal([Daypart.Prime], conditions.Dayparts);
}
[Fact]
public async Task UpdateElement_UnknownElement_ReturnsNotFound()
{
var (fixture, junctionId, _, groupId) = await SeedAsync();
await using var db = fixture.New();
var result = await new UpdateJunctionElementCommandHandler(db).Handle(
new UpdateJunctionElementCommand(junctionId, Guid.NewGuid(), Input(groupId: groupId)),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionElementNotFound, result.Error);
}
[Fact]
public async Task Reorder_AppliesOrderAndForkMembership()
{
var (fixture, junctionId, _, _) = await SeedAsync();
Guid first;
Guid second;
await using (var db = fixture.New())
{
var junction = await db
.JunctionTemplates.Include(j => j.Elements)
.SingleAsync(j => j.Id == junctionId);
first = junction.AddElement(JunctionElementKind.Ad).Id;
second = junction.AddElement(JunctionElementKind.Promo).Id;
await db.SaveChangesAsync(CancellationToken.None);
}
await using (var db = fixture.New())
{
// Перетаскивание меняет порядок и принадлежность к развилке одним жестом.
var result = await new ReorderJunctionCommandHandler(db).Handle(
new ReorderJunctionCommand(
junctionId,
[
new JunctionElementOrder(second, "fork"),
new JunctionElementOrder(first, "fork"),
]
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var verify = fixture.New();
var elements = await verify
.JunctionElements.AsNoTracking()
.OrderBy(e => e.Position)
.ToListAsync();
Assert.Equal(second, elements[0].Id);
Assert.All(elements, e => Assert.Equal("fork", e.ChoiceKey));
}
[Fact]
public async Task Reorder_UnknownJunction_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new ReorderJunctionCommandHandler(db).Handle(
new ReorderJunctionCommand(Guid.NewGuid(), []),
CancellationToken.None
);
Assert.Equal(TemplateErrors.JunctionNotFound, result.Error);
}
[Fact]
public async Task List_ResolvesNamesAndChannelUsage()
{
var (fixture, junctionId, templateId, groupId) = await SeedAsync();
var bumper = BumperTemplate.Create("Блок", "Текст 1");
await using (var db = fixture.New())
{
db.BumperTemplates.Add(bumper);
var junction = await db
.JunctionTemplates.Include(j => j.Elements)
.SingleAsync(j => j.Id == junctionId);
junction.Update("Прайм", 240);
junction
.AddElement(JunctionElementKind.Ad)
.Update(
new JunctionElementSettings(
JunctionElementKind.Ad,
groupId,
null,
null,
JunctionAmountMode.Count,
2,
true,
null,
1,
null
)
);
junction
.AddElement(JunctionElementKind.Bumper)
.Update(
new JunctionElementSettings(
JunctionElementKind.Bumper,
null,
bumper.Id,
bumper.Variants[0].Id,
JunctionAmountMode.Count,
1,
false,
"fork",
3,
new JunctionConditions(OnlyOnElementChange: true).ToJson()
)
);
// Второй канал ссылается на тот же стык — счётчик обязан это увидеть.
var other = Channel.Create("Второй", "two", T0);
var otherTemplate = ScheduleTemplate.Create(other.Id, "Сетка 2");
otherTemplate.SetDefaultJunction(junctionId);
other.SetTemplate(otherTemplate.Id);
db.Channels.Add(other);
db.ScheduleTemplates.Add(otherTemplate);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var read = fixture.New();
var junctions = await new ListJunctionsQueryHandler(read).Handle(
new ListJunctionsQuery(),
CancellationToken.None
);
var dto = Assert.Single(junctions);
Assert.Equal("Прайм", dto.Name);
Assert.Equal(240, dto.MaxTotalSeconds);
Assert.Equal(2, dto.ChannelUsageCount);
Assert.Equal("Реклама", dto.Elements[0].GroupName);
Assert.Equal("Блок", dto.Elements[1].BumperTemplateName);
Assert.Equal("Текст 1", dto.Elements[1].BumperVariantName);
Assert.Equal("fork", dto.Elements[1].ChoiceKey);
Assert.True(dto.Elements[1].Conditions!.OnlyOnElementChange);
Assert.Equal(templateId, templateId);
}
[Fact]
public void Conditions_RoundTripAndDaypartFilter()
{
var conditions = new JunctionConditions(
true,
30,
[Daypart.Prime, Daypart.Night],
new JunctionTimeWindow(new TimeOnly(23, 0), new TimeOnly(6, 0)),
40
);
var restored = JunctionConditions.FromJson(conditions.ToJson())!;
// Сравниваем по полям: у записи со списком внутри равенство ссылочное.
Assert.Equal(conditions.OnlyOnElementChange, restored.OnlyOnElementChange);
Assert.Equal(conditions.MinMinutesBetween, restored.MinMinutesBetween);
Assert.Equal(conditions.Dayparts, restored.Dayparts);
Assert.Equal(conditions.TimeWindow, restored.TimeWindow);
Assert.Equal(conditions.Chance, restored.Chance);
Assert.True(restored.AllowsDaypart(Daypart.Prime));
Assert.False(restored.AllowsDaypart(Daypart.Morning));
// Пустой список дейпартов не ограничивает ничего — иначе врезка молча исчезла бы из эфира.
Assert.True(new JunctionConditions().AllowsDaypart(Daypart.Morning));
Assert.Null(JunctionConditions.FromJson("{не json}"));
Assert.Null(JunctionConditions.FromJson(null));
}
}