Normalize line endings to LF via .gitattributes

Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
@@ -1,298 +1,298 @@
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext();
var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc)
.ToList();
Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added);
}
[SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id)
.ToHashSet();
Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id)
.ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext();
var programs = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.ToList();
Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
}
[SkippableFact]
public async Task Preview_WritesNothing()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains(
report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
);
await using var verify = fixture.CreateContext();
Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
);
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
{
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(db),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db),
random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
}
private sealed record World(
Guid ChannelId,
Guid TemplateId,
Guid SlotId,
Guid ShowId,
Guid? CollectionId
);
/// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary>
private static async Task<World> SeedAsync(
AppDbContext db,
int episodes,
bool asCollection = false
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++)
{
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id);
}
db.Shows.Add(show);
Collection? collection = null;
if (asCollection)
{
collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id);
db.Collections.Add(collection);
}
var group = Group.Create($"Группа {suffix}");
group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id
);
db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
duration,
SegmentSeconds: 2,
SegmentCount: (int)(duration.TotalSeconds / 2),
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
RelativePath: $"segments/{asset.Id}"
)
);
db.MediaAssets.Add(asset);
return asset;
}
}
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext();
var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc)
.ToList();
Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added);
}
[SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id)
.ToHashSet();
Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id)
.ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext();
var programs = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.ToList();
Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
}
[SkippableFact]
public async Task Preview_WritesNothing()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains(
report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
);
await using var verify = fixture.CreateContext();
Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
);
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
{
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(db),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db),
random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
}
private sealed record World(
Guid ChannelId,
Guid TemplateId,
Guid SlotId,
Guid ShowId,
Guid? CollectionId
);
/// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary>
private static async Task<World> SeedAsync(
AppDbContext db,
int episodes,
bool asCollection = false
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++)
{
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id);
}
db.Shows.Add(show);
Collection? collection = null;
if (asCollection)
{
collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id);
db.Collections.Add(collection);
}
var group = Group.Create($"Группа {suffix}");
group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id
);
db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
duration,
SegmentSeconds: 2,
SegmentCount: (int)(duration.TotalSeconds / 2),
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
RelativePath: $"segments/{asset.Id}"
)
);
db.MediaAssets.Add(asset);
return asset;
}
}
@@ -1,176 +1,176 @@
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
/// </summary>
[Collection("postgres")]
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var first = $"{tag}-s01e01.mkv";
var second = $"{tag}-s01e02.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId
),
default
);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Imported);
Assert.Empty(result.Value.Failed);
await db.SaveChangesAsync();
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
await storage
.Received(2)
.PromoteToOriginalAsync(
MediaSource.ManualInbox,
Arg.Any<string>(),
Arg.Any<Guid>(),
".mkv",
Arg.Any<CancellationToken>()
);
await using var verify = fixture.CreateContext();
var episodes = verify
.Shows.Where(s => s.Id == showId)
.SelectMany(s => s.Episodes)
.OrderBy(e => e.Position)
.ToList();
Assert.Equal(2, episodes.Count);
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
Assert.Equal(1, episodes[0].Season);
Assert.Equal(1, episodes[0].Episode);
Assert.Equal(2, episodes[1].Episode);
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
// заводит записи из ручного inbox.
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
Assert.Equal(2, assets.Count);
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
}
[SkippableFact]
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var good = $"{tag}-ok.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile(good, good, 1000),
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId
),
default
);
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.Imported);
Assert.Equal(2, result.Value.Failed.Count);
Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt");
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
}
[SkippableFact]
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
await using var verify = fixture.CreateContext();
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
Assert.Equal(4, episode.Season);
Assert.Equal(12, episode.Episode);
// И спутники рядом с забранным файлом убираются.
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
}
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
private static async Task<Guid> SeedShowAsync(AppDbContext db)
{
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
db.Shows.Add(show);
await db.SaveChangesAsync();
return show.Id;
}
}
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
/// </summary>
[Collection("postgres")]
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var first = $"{tag}-s01e01.mkv";
var second = $"{tag}-s01e02.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId
),
default
);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Imported);
Assert.Empty(result.Value.Failed);
await db.SaveChangesAsync();
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
await storage
.Received(2)
.PromoteToOriginalAsync(
MediaSource.ManualInbox,
Arg.Any<string>(),
Arg.Any<Guid>(),
".mkv",
Arg.Any<CancellationToken>()
);
await using var verify = fixture.CreateContext();
var episodes = verify
.Shows.Where(s => s.Id == showId)
.SelectMany(s => s.Episodes)
.OrderBy(e => e.Position)
.ToList();
Assert.Equal(2, episodes.Count);
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
Assert.Equal(1, episodes[0].Season);
Assert.Equal(1, episodes[0].Episode);
Assert.Equal(2, episodes[1].Episode);
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
// заводит записи из ручного inbox.
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
Assert.Equal(2, assets.Count);
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
}
[SkippableFact]
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var good = $"{tag}-ok.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile(good, good, 1000),
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId
),
default
);
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.Imported);
Assert.Equal(2, result.Value.Failed.Count);
Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt");
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
}
[SkippableFact]
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
await using var verify = fixture.CreateContext();
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
Assert.Equal(4, episode.Season);
Assert.Equal(12, episode.Episode);
// И спутники рядом с забранным файлом убираются.
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
}
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
private static async Task<Guid> SeedShowAsync(AppDbContext db)
{
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
db.Shows.Add(show);
await db.SaveChangesAsync();
return show.Id;
}
}
@@ -1,70 +1,70 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Infrastructure.Persistence;
using Testcontainers.PostgreSql;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
private PostgreSqlContainer? _container;
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
public bool Available { get; private set; }
public string ConnectionString =>
_container?.GetConnectionString()
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
public async Task InitializeAsync()
{
try
{
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
await _container.StartAsync();
await using var db = CreateContext();
await db.Database.MigrateAsync();
Available = true;
}
catch (Exception)
{
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
Available = false;
}
}
public AppDbContext CreateContext() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
public async Task DisposeAsync()
{
if (_container is not null)
await _container.DisposeAsync();
}
}
[CollectionDefinition("postgres")]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
internal sealed class SequenceRandom(params int[] sequence)
: Domain.Broadcast.Scheduling.IRandomSource
{
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
private int _i;
public int Next(int maxExclusive)
{
if (maxExclusive <= 0)
return 0;
var value = _sequence[_i++ % _sequence.Length];
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
}
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Infrastructure.Persistence;
using Testcontainers.PostgreSql;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
private PostgreSqlContainer? _container;
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
public bool Available { get; private set; }
public string ConnectionString =>
_container?.GetConnectionString()
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
public async Task InitializeAsync()
{
try
{
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
await _container.StartAsync();
await using var db = CreateContext();
await db.Database.MigrateAsync();
Available = true;
}
catch (Exception)
{
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
Available = false;
}
}
public AppDbContext CreateContext() =>
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
public async Task DisposeAsync()
{
if (_container is not null)
await _container.DisposeAsync();
}
}
[CollectionDefinition("postgres")]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
internal sealed class SequenceRandom(params int[] sequence)
: Domain.Broadcast.Scheduling.IRandomSource
{
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
private int _i;
public int Next(int maxExclusive)
{
if (maxExclusive <= 0)
return 0;
var value = _sequence[_i++ % _sequence.Length];
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
}
}
@@ -1,183 +1,183 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
/// </summary>
[Collection("postgres")]
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new CopyTemplateCommandHandler(db).Handle(
new CopyTemplateCommand(sourceId, targetId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
Assert.Equal(1, result.Value.Layers);
Assert.Equal(1, result.Value.Slots);
Assert.Equal(1, result.Value.Junctions);
await using var verify = fixture.CreateContext();
var target = verify.Channels.Single(c => c.Id == targetId);
var copied = verify
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.Single(t => t.ChannelId == targetId);
// У приёмника ровно один шаблон, и канал смотрит именно на него.
Assert.Equal(copied.Id, target.TemplateId);
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
// Группы общие — ссылка переносится как есть, а не копией группы.
Assert.Equal(groupId, slot.GroupId);
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
Assert.Equal(junction.Id, slot.JunctionAfterId);
Assert.NotEqual(
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
slot.JunctionAfterId
);
}
[Fact]
public void Copy_ToItself_IsRejectedByValidator()
{
var validator = new CopyTemplateCommandValidator();
var id = Guid.NewGuid();
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
}
[SkippableFact]
public async Task Validate_ReportsEmptyGroupAndGridGap()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, _, _) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(sourceId),
default
);
Assert.True(result.IsSuccess);
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
}
[SkippableFact]
public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
// Канал из старой ротации: сетки нет, ссылки на неё тоже.
await using var seedDb = fixture.CreateContext();
var suffix = Guid.NewGuid().ToString("N")[..8];
var channel = Channel.Create(
$"Без сетки {suffix}",
$"nogrid-{suffix}",
DateTimeOffset.UtcNow
);
seedDb.Channels.Add(channel);
await seedDb.SaveChangesAsync();
await using var db = fixture.CreateContext();
var created = await new CreateChannelTemplateCommandHandler(db).Handle(
new CreateChannelTemplateCommand(channel.Id),
default
);
Assert.True(created.IsSuccess);
await db.SaveChangesAsync();
await using var again = fixture.CreateContext();
var second = await new CreateChannelTemplateCommandHandler(again).Handle(
new CreateChannelTemplateCommand(channel.Id),
default
);
await again.SaveChangesAsync();
// Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется.
Assert.True(second.IsSuccess);
Assert.Equal(created.Value, second.Value);
await using var verify = fixture.CreateContext();
var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id);
Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId);
// Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть.
Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground));
}
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(
AppDbContext db
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var group = Group.Create($"Группа {suffix}");
db.Groups.Add(group);
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
var junction = JunctionTemplate.Create(source.Id, "Прайм");
var ad = junction.AddElement(JunctionElementKind.Ad);
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
db.JunctionTemplates.Add(junction);
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
var layer = sourceTemplate.AddLayer("Прайм", 10);
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
JunctionAfterId: junction.Id
)
);
sourceTemplate.SetDefaultJunction(junction.Id);
source.SetTemplate(sourceTemplate.Id);
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
target.SetTemplate(targetTemplate.Id);
db.Channels.AddRange(source, target);
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
await db.SaveChangesAsync();
return (source.Id, target.Id, group.Id);
}
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
/// </summary>
[Collection("postgres")]
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new CopyTemplateCommandHandler(db).Handle(
new CopyTemplateCommand(sourceId, targetId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
Assert.Equal(1, result.Value.Layers);
Assert.Equal(1, result.Value.Slots);
Assert.Equal(1, result.Value.Junctions);
await using var verify = fixture.CreateContext();
var target = verify.Channels.Single(c => c.Id == targetId);
var copied = verify
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.Single(t => t.ChannelId == targetId);
// У приёмника ровно один шаблон, и канал смотрит именно на него.
Assert.Equal(copied.Id, target.TemplateId);
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
// Группы общие — ссылка переносится как есть, а не копией группы.
Assert.Equal(groupId, slot.GroupId);
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
Assert.Equal(junction.Id, slot.JunctionAfterId);
Assert.NotEqual(
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
slot.JunctionAfterId
);
}
[Fact]
public void Copy_ToItself_IsRejectedByValidator()
{
var validator = new CopyTemplateCommandValidator();
var id = Guid.NewGuid();
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
}
[SkippableFact]
public async Task Validate_ReportsEmptyGroupAndGridGap()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var (sourceId, _, _) = await SeedPairAsync(seedDb);
await using var db = fixture.CreateContext();
var result = await new ValidateTemplateQueryHandler(db).Handle(
new ValidateTemplateQuery(sourceId),
default
);
Assert.True(result.IsSuccess);
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
}
[SkippableFact]
public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
// Канал из старой ротации: сетки нет, ссылки на неё тоже.
await using var seedDb = fixture.CreateContext();
var suffix = Guid.NewGuid().ToString("N")[..8];
var channel = Channel.Create(
$"Без сетки {suffix}",
$"nogrid-{suffix}",
DateTimeOffset.UtcNow
);
seedDb.Channels.Add(channel);
await seedDb.SaveChangesAsync();
await using var db = fixture.CreateContext();
var created = await new CreateChannelTemplateCommandHandler(db).Handle(
new CreateChannelTemplateCommand(channel.Id),
default
);
Assert.True(created.IsSuccess);
await db.SaveChangesAsync();
await using var again = fixture.CreateContext();
var second = await new CreateChannelTemplateCommandHandler(again).Handle(
new CreateChannelTemplateCommand(channel.Id),
default
);
await again.SaveChangesAsync();
// Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется.
Assert.True(second.IsSuccess);
Assert.Equal(created.Value, second.Value);
await using var verify = fixture.CreateContext();
var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id);
Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId);
// Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть.
Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground));
}
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(
AppDbContext db
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var group = Group.Create($"Группа {suffix}");
db.Groups.Add(group);
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
var junction = JunctionTemplate.Create(source.Id, "Прайм");
var ad = junction.AddElement(JunctionElementKind.Ad);
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
db.JunctionTemplates.Add(junction);
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
var layer = sourceTemplate.AddLayer("Прайм", 10);
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext,
JunctionAfterId: junction.Id
)
);
sourceTemplate.SetDefaultJunction(junction.Id);
source.SetTemplate(sourceTemplate.Id);
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
target.SetTemplate(targetTemplate.Id);
db.Channels.AddRange(source, target);
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
await db.SaveChangesAsync();
return (source.Id, target.Id, group.Id);
}
}