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)); } }