diff --git a/backend/tests/TeleWave.Application.Tests/Admin/ListUsersTests.cs b/backend/tests/TeleWave.Application.Tests/Admin/ListUsersTests.cs
new file mode 100644
index 0000000..6ce4cc8
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Admin/ListUsersTests.cs
@@ -0,0 +1,60 @@
+using NSubstitute;
+using TeleWave.Application.Admin.Users.ListUsers;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Admin;
+
+///
+/// Список пользователей: хендлер только перекладывает параметры запроса в порт — Identity живёт
+/// вне IAppDbContext. Проверяется именно перекладка: потерянный фильтр здесь означал бы
+/// заблокированных вперемешку с активными.
+///
+public class ListUsersTests
+{
+ [Fact]
+ public async Task PassesEveryFilterToIdentityService()
+ {
+ var identity = Substitute.For();
+ var roleId = Guid.NewGuid();
+ var page = new PagedList([], 0, 2, 25);
+ identity
+ .ListUsersAsync(Arg.Any(), Arg.Any())
+ .Returns(page);
+
+ var result = await new ListUsersQueryHandler(identity).Handle(
+ new ListUsersQuery(2, 25, "иван", roleId, IsBlocked: true, Sort: "created", Desc: true),
+ CancellationToken.None
+ );
+
+ Assert.Same(page, result);
+ await identity
+ .Received(1)
+ .ListUsersAsync(
+ new UserListFilter(2, 25, "иван", roleId, true, "created", true),
+ Arg.Any()
+ );
+ }
+
+ [Fact]
+ public async Task DefaultsSortAndDirection_WhenNotAsked()
+ {
+ var identity = Substitute.For();
+ identity
+ .ListUsersAsync(Arg.Any(), Arg.Any())
+ .Returns(new PagedList([], 0, 1, 20));
+
+ await new ListUsersQueryHandler(identity).Handle(
+ new ListUsersQuery(1, 20, null, null, null),
+ CancellationToken.None
+ );
+
+ await identity
+ .Received(1)
+ .ListUsersAsync(
+ Arg.Is(f => f.Sort == null && !f.Desc && f.Search == null),
+ Arg.Any()
+ );
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Library/LibraryQueriesTests.cs b/backend/tests/TeleWave.Application.Tests/Library/LibraryQueriesTests.cs
new file mode 100644
index 0000000..8543c37
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Library/LibraryQueriesTests.cs
@@ -0,0 +1,182 @@
+using TeleWave.Application.Library.Collections.GetCollection;
+using TeleWave.Application.Library.Collections.ListCollections;
+using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
+using TeleWave.Application.Library.Interstitials.ListInterstitials;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Library;
+
+///
+/// Справочные списки библиотеки: коллекции и ролики. Считают они немного, но каждое число на экране
+/// админа приходит отсюда — сериал внутри франшизы, длительность блока рекламы, статус обработки.
+///
+public class LibraryQueriesTests
+{
+ /// Шоу с готовыми ассетами: без длительностей в списках нечего показывать.
+ private static (Show Show, List Assets) Playable(
+ string name,
+ ShowKind kind,
+ int episodes,
+ int minutes
+ )
+ {
+ var show = Show.Create(name, kind);
+ var assets = new List();
+
+ for (var i = 0; i < episodes; i++)
+ {
+ var asset = MediaAsset.Register($"{name}-{i}.mkv", ".mkv", MediaSource.Upload);
+ asset.MarkProcessing();
+ asset.MarkReady(
+ new MediaReadyInfo(
+ TimeSpan.FromMinutes(minutes),
+ 6,
+ minutes * 10,
+ 1920,
+ 1080,
+ "h264",
+ "aac",
+ $"assets/{name}-{i}"
+ )
+ );
+ assets.Add(asset);
+ show.AddEpisode(asset.Id);
+ }
+
+ return (show, assets);
+ }
+
+ [Fact]
+ public async Task Collections_CountUnitsNotParts()
+ {
+ var fixture = new TestDb();
+ var series = Playable("Сериал", ShowKind.Series, 10, 25);
+ var film = Playable("Фильм", ShowKind.Single, 1, 100);
+
+ var collection = Collection.Create("Франшиза", "Описание");
+ collection.AddShow(film.Show.Id);
+ collection.AddShow(series.Show.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.AddRange([.. series.Assets, .. film.Assets]);
+ seed.Shows.AddRange(series.Show, film.Show);
+ seed.Collections.Add(collection);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var list = await new ListCollectionsQueryHandler(db).Handle(
+ new ListCollectionsQuery(),
+ CancellationToken.None
+ );
+
+ var summary = Assert.Single(list);
+ Assert.Equal("Франшиза", summary.Name);
+ Assert.Equal(2, summary.ItemCount);
+ // Единиц больше, чем частей: сериал внутри коллекции разворачивается в свои серии.
+ Assert.Equal(11, summary.UnitCount);
+
+ var detail = await new GetCollectionQueryHandler(db).Handle(
+ new GetCollectionQuery(collection.Id),
+ CancellationToken.None
+ );
+
+ Assert.True(detail.IsSuccess);
+ // Порядок частей — заданный, а не алфавитный: франшиза смотрится по порядку выхода.
+ Assert.Equal(["Фильм", "Сериал"], detail.Value.Items.Select(i => i.ShowName));
+ Assert.Equal(10, detail.Value.Items[1].EpisodeCount);
+ }
+
+ [Fact]
+ public async Task GetCollection_FailsForUnknownId()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var result = await new GetCollectionQueryHandler(db).Handle(
+ new GetCollectionQuery(Guid.NewGuid()),
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public async Task Interstitials_ShowDurationAndStatus()
+ {
+ var fixture = new TestDb();
+ var ready = Playable("Реклама", ShowKind.Interstitial, 1, 1);
+ // Ролик без медиа: заведён, но файла к нему ещё не приложили.
+ var empty = Show.Create("Пустой ролик", ShowKind.Interstitial);
+ var notAClip = Playable("Кино", ShowKind.Single, 1, 100);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.AddRange([.. ready.Assets, .. notAClip.Assets]);
+ seed.Shows.AddRange(ready.Show, empty, notAClip.Show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var list = await new ListInterstitialsQueryHandler(db).Handle(
+ new ListInterstitialsQuery(),
+ CancellationToken.None
+ );
+
+ // В списке роликов только ролики — полнометражка сюда не попадает.
+ Assert.Equal(2, list.Count);
+
+ var withMedia = list.Single(i => i.Name == "Реклама");
+ Assert.Equal(MediaAssetStatus.Ready, withMedia.AssetStatus);
+ Assert.Equal(60, withMedia.DurationSeconds);
+
+ var withoutMedia = list.Single(i => i.Name == "Пустой ролик");
+ Assert.Null(withoutMedia.MediaAssetId);
+ Assert.Null(withoutMedia.AssetStatus);
+ Assert.Null(withoutMedia.DurationSeconds);
+ }
+
+ [Fact]
+ public async Task Blocks_AreCollectionsMadeOfClipsOnly()
+ {
+ var fixture = new TestDb();
+ var first = Playable("Ролик 1", ShowKind.Interstitial, 1, 1);
+ var second = Playable("Ролик 2", ShowKind.Interstitial, 1, 2);
+ var film = Playable("Кино", ShowKind.Single, 1, 100);
+
+ var block = Collection.Create("Рекламный блок");
+ block.AddShow(first.Show.Id);
+ block.AddShow(second.Show.Id);
+
+ // Смешанная коллекция — франшиза, а не блок: она живёт на своём экране.
+ var mixed = Collection.Create("Смешанная");
+ mixed.AddShow(first.Show.Id);
+ mixed.AddShow(film.Show.Id);
+
+ // Пустая коллекция блоком тоже не считается: ставить в эфир из неё нечего.
+ var empty = Collection.Create("Пустая");
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.AddRange([.. first.Assets, .. second.Assets, .. film.Assets]);
+ seed.Shows.AddRange(first.Show, second.Show, film.Show);
+ seed.Collections.AddRange(block, mixed, empty);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var blocks = await new ListInterstitialBlocksQueryHandler(db).Handle(
+ new ListInterstitialBlocksQuery(),
+ CancellationToken.None
+ );
+
+ var only = Assert.Single(blocks);
+ Assert.Equal("Рекламный блок", only.Name);
+ Assert.Equal(2, only.ItemCount);
+ Assert.Equal(180, only.DurationSeconds);
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Media/MediaQueueTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MediaQueueTests.cs
new file mode 100644
index 0000000..9e07dd3
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Media/MediaQueueTests.cs
@@ -0,0 +1,47 @@
+using Microsoft.Extensions.Options;
+using TeleWave.Infrastructure.Media;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Media;
+
+///
+/// Сигнальная очередь конвейера и предел параллельности. Мелочь, но на первой держится реакция
+/// обработчика на новый файл, а на втором — оценка «сколько ещё ждать».
+///
+public class MediaQueueTests
+{
+ [Fact]
+ public async Task Queue_WakesUpOnce_ForAWholeBurstOfSignals()
+ {
+ var queue = new MediaProcessingQueue();
+
+ queue.Enqueue(Guid.NewGuid());
+ queue.Enqueue(Guid.NewGuid());
+ queue.Enqueue(Guid.NewGuid());
+
+ // Первый вызов возвращается сразу и дренирует накопленное: работу обработчик всё равно
+ // берёт из БД пачкой, и три сигнала не должны означать три круга.
+ await queue.WaitAsync(CancellationToken.None);
+
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+ // Очередь пуста — следующий вызов ждёт нового сигнала, а не возвращается снова.
+ await Assert.ThrowsAnyAsync(async () =>
+ await queue.WaitAsync(cts.Token)
+ );
+ }
+
+ [Theory]
+ [InlineData(4, 4)]
+ [InlineData(0, 1)]
+ [InlineData(-2, 1)]
+ public void Limits_NeverReportLessThanOneTranscode(int configured, int expected)
+ {
+ var limits = new MediaProcessingLimits(
+ Options.Create(new MediaOptions { MaxParallelTranscodes = configured })
+ );
+
+ // Ноль и отрицательное в настройках — не повод делить оценку очереди на ноль.
+ Assert.Equal(expected, limits.MaxParallelTranscodes);
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs
new file mode 100644
index 0000000..2271ccd
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs
@@ -0,0 +1,271 @@
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Metadata;
+using TeleWave.Application.Metadata.RefreshEpisodes;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Metadata;
+
+///
+/// Довыгрузка метаданных серий: что берётся за номер серии, что происходит с кадром и когда
+/// операция отказывается работать вовсе.
+///
+public class RefreshEpisodesTests
+{
+ private static EpisodeMetadata Meta(string title, string? stillUrl = null) =>
+ new(title, "Описание", stillUrl, new DateOnly(2005, 3, 20));
+
+ private static (IMetadataProviderResolver Resolver, IMetadataProvider Provider) ProviderFor(
+ string key
+ )
+ {
+ var provider = Substitute.For();
+ var resolver = Substitute.For();
+ resolver.Resolve(key).Returns(provider);
+ return (resolver, provider);
+ }
+
+ private static RefreshShowEpisodesMetadataCommandHandler Handler(
+ IAppDbContext db,
+ IMetadataProviderResolver resolver,
+ IImageDownloader? downloader = null,
+ IImageStore? store = null
+ ) =>
+ new(
+ db,
+ resolver,
+ downloader ?? Substitute.For(),
+ store ?? Substitute.For()
+ );
+
+ /// Сериал с привязкой к источнику и одной серией на готовом ассете.
+ private static (Show Show, MediaAsset Asset) Linked(string fileName, bool withNumbers)
+ {
+ var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
+ var show = Show.Create("Симпсоны", ShowKind.Series);
+ show.ApplyMetadata("tmdb", "456", null, 2005, null);
+ var episode = show.AddEpisode(asset.Id);
+ if (withNumbers)
+ episode.SetNumbers(16, 21);
+ return (show, asset);
+ }
+
+ [Fact]
+ public async Task AppliesMetadata_AndSavesStillToRegistry()
+ {
+ var fixture = new TestDb();
+ var (show, asset) = Linked("s16e21.mkv", withNumbers: true);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.Add(asset);
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var (resolver, provider) = ProviderFor("tmdb");
+ provider
+ .GetEpisodeAsync("456", 16, 21, Arg.Any())
+ .Returns(Meta("The Father, the Son", "https://img/still.jpg"));
+
+ var downloader = Substitute.For();
+ downloader
+ .DownloadAsync("https://img/still.jpg", Arg.Any())
+ .Returns(new DownloadedImage([1, 2, 3], ".jpg"));
+ var store = Substitute.For();
+
+ await using var db = fixture.New();
+ var result = await Handler(db, resolver, downloader, store)
+ .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(1, result.Value);
+
+ await using var check = fixture.New();
+ var stored = await check.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None);
+ var episode = stored.Episodes.Single();
+ Assert.Equal("The Father, the Son", episode.Title);
+ Assert.NotNull(episode.StillImageId);
+ // Кадр попадает в общий реестр изображений, а не в своё файловое поле.
+ Assert.Equal(1, await check.Images.CountAsync(CancellationToken.None));
+ await store
+ .Received(1)
+ .SaveAsync(
+ episode.StillImageId!.Value,
+ ".jpg",
+ Arg.Any(),
+ Arg.Any()
+ );
+ }
+
+ [Fact]
+ public async Task TakesNumbersFromFileName_WhenNotSet()
+ {
+ var fixture = new TestDb();
+ var (show, asset) = Linked("Simpsons.S16E21.1080p.mkv", withNumbers: false);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.Add(asset);
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var (resolver, provider) = ProviderFor("tmdb");
+ provider
+ .GetEpisodeAsync("456", 16, 21, Arg.Any())
+ .Returns(Meta("Разобрано по имени"));
+
+ await using var db = fixture.New();
+ var result = await Handler(db, resolver)
+ .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ Assert.Equal(1, result.Value);
+
+ await using var check = fixture.New();
+ var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single();
+ // Разобранные номера тут же сохраняются: второй раз имя файла разбирать незачем.
+ Assert.Equal(16, episode.Season);
+ Assert.Equal(21, episode.Episode);
+ }
+
+ [Fact]
+ public async Task SkipsEpisode_WhenNumbersUnknownOrSourceSilent()
+ {
+ var fixture = new TestDb();
+ var unparsable = MediaAsset.Register("случайное имя.mkv", ".mkv", MediaSource.Upload);
+ var known = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload);
+ var show = Show.Create("Сериал", ShowKind.Series);
+ show.ApplyMetadata("tmdb", "1", null, null, null);
+ show.AddEpisode(unparsable.Id);
+ show.AddEpisode(known.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.AddRange(unparsable, known);
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var (resolver, provider) = ProviderFor("tmdb");
+ // Источник знает не всё: на распознанную серию он отвечает пустотой.
+ provider
+ .GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((EpisodeMetadata?)null);
+
+ await using var db = fixture.New();
+ var result = await Handler(db, resolver)
+ .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(0, result.Value);
+ // К нераспознанной серии источник не ходит вовсе — незачем.
+ await provider
+ .Received(1)
+ .GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task KeepsMetadata_WhenStillCannotBeDownloaded()
+ {
+ var fixture = new TestDb();
+ var (show, asset) = Linked("s01e01.mkv", withNumbers: true);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.Add(asset);
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var (resolver, provider) = ProviderFor("tmdb");
+ provider
+ .GetEpisodeAsync("456", Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Meta("Без кадра", "https://img/broken.jpg"));
+
+ var downloader = Substitute.For();
+ downloader
+ .DownloadAsync(Arg.Any(), Arg.Any())
+ .Returns((DownloadedImage?)null);
+
+ await using var db = fixture.New();
+ var result = await Handler(db, resolver, downloader)
+ .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
+ await db.SaveChangesAsync(CancellationToken.None);
+
+ // Недоступный кадр не повод отменять текст: серия обновлена, картинки просто нет.
+ Assert.Equal(1, result.Value);
+
+ await using var check = fixture.New();
+ var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single();
+ Assert.Equal("Без кадра", episode.Title);
+ Assert.Null(episode.StillImageId);
+ Assert.Equal(0, await check.Images.CountAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Fails_WhenShowIsMissingOrNotSeriesOrUnlinked()
+ {
+ var fixture = new TestDb();
+ var movie = Show.Create("Фильм", ShowKind.Single);
+ var unlinked = Show.Create("Сериал без источника", ShowKind.Series);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.AddRange(movie, unlinked);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var (resolver, _) = ProviderFor("tmdb");
+
+ await using var db = fixture.New();
+ var handler = Handler(db, resolver);
+
+ var missing = await handler.Handle(
+ new RefreshShowEpisodesMetadataCommand(Guid.NewGuid()),
+ CancellationToken.None
+ );
+ var notSeries = await handler.Handle(
+ new RefreshShowEpisodesMetadataCommand(movie.Id),
+ CancellationToken.None
+ );
+ var noSource = await handler.Handle(
+ new RefreshShowEpisodesMetadataCommand(unlinked.Id),
+ CancellationToken.None
+ );
+
+ Assert.False(missing.IsSuccess);
+ Assert.Equal(MetadataErrors.EpisodesForSeriesOnly, notSeries.Error);
+ Assert.Equal(MetadataErrors.NoLinkedSource, noSource.Error);
+ }
+
+ [Fact]
+ public async Task Fails_WhenLinkedProviderIsNotConfigured()
+ {
+ var fixture = new TestDb();
+ var show = Show.Create("Сериал", ShowKind.Series);
+ show.ApplyMetadata("omdb", "tt1", null, null, null);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Shows.Add(show);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var resolver = Substitute.For();
+ // Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null.
+ resolver.Resolve(Arg.Any()).Returns((IMetadataProvider?)null);
+
+ await using var db = fixture.New();
+ var result = await Handler(db, resolver)
+ .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None);
+
+ Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error);
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/SearchShowMetadataTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/SearchShowMetadataTests.cs
new file mode 100644
index 0000000..2902053
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Metadata/SearchShowMetadataTests.cs
@@ -0,0 +1,89 @@
+using NSubstitute;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Metadata;
+using TeleWave.Application.Metadata.SearchShows;
+using TeleWave.Domain.Library;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Metadata;
+
+///
+/// Поиск кандидатов в источнике. Внешняя сеть — единственная часть метаданных, которая падает
+/// регулярно, поэтому проверяется в первую очередь она.
+///
+public class SearchShowMetadataTests
+{
+ private static SearchShowMetadataQueryHandler Handler(IMetadataProvider? provider)
+ {
+ var resolver = Substitute.For();
+ // Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null.
+ resolver.Resolve(Arg.Any()).Returns(provider);
+ return new SearchShowMetadataQueryHandler(resolver);
+ }
+
+ [Fact]
+ public async Task ReturnsWhatProviderFound()
+ {
+ var provider = Substitute.For();
+ provider
+ .SearchShowsAsync("Симпсоны", ShowKind.Series, Arg.Any())
+ .Returns([new MetadataCandidate("456", "The Simpsons", 1989, null, null)]);
+
+ var result = await Handler(provider)
+ .Handle(
+ new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series),
+ CancellationToken.None
+ );
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("456", Assert.Single(result.Value).ExternalId);
+ }
+
+ [Fact]
+ public async Task ReturnsNothing_ForBlankQuery_WithoutCallingProvider()
+ {
+ var provider = Substitute.For();
+
+ var result = await Handler(provider)
+ .Handle(
+ new SearchShowMetadataQuery("tmdb", " ", ShowKind.Single),
+ CancellationToken.None
+ );
+
+ Assert.True(result.IsSuccess);
+ Assert.Empty(result.Value);
+ await provider
+ .DidNotReceive()
+ .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task Fails_WhenProviderIsNotConfigured()
+ {
+ var result = await Handler(null)
+ .Handle(
+ new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series),
+ CancellationToken.None
+ );
+
+ Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error);
+ }
+
+ [Fact]
+ public async Task Fails_WhenSourceIsUnreachableOrRejectsTheKey()
+ {
+ var provider = Substitute.For();
+ provider
+ .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns>(_ => throw new HttpRequestException("401"));
+
+ var result = await Handler(provider)
+ .Handle(
+ new SearchShowMetadataQuery("tmdb", "Симпсоны", ShowKind.Series),
+ CancellationToken.None
+ );
+
+ // Сетевой сбой и неверный ключ — управляемая ошибка, а не исключение наружу.
+ Assert.Equal(MetadataErrors.ProviderRequestFailed, result.Error);
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/FindGroupCandidatesTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/FindGroupCandidatesTests.cs
new file mode 100644
index 0000000..68a9cdd
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/FindGroupCandidatesTests.cs
@@ -0,0 +1,117 @@
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Programming.Groups.FindGroupCandidates;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Подбор позиций по правилу набора: то, что редактор группы показывает кнопкой «Подобрать».
+/// Важна не только выборка, но и отметка «уже в группе» — по ней видно, что добавлять нечего.
+///
+public class FindGroupCandidatesTests
+{
+ private static (Show Show, MediaAsset Asset) Playable(string name, int minutes)
+ {
+ var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload);
+ asset.MarkProcessing();
+ asset.MarkReady(
+ new MediaReadyInfo(
+ TimeSpan.FromMinutes(minutes),
+ 6,
+ minutes * 10,
+ 1920,
+ 1080,
+ "h264",
+ "aac",
+ $"assets/{name}"
+ )
+ );
+ var show = Show.Create(name, ShowKind.Single);
+ show.AddEpisode(asset.Id);
+ return (show, asset);
+ }
+
+ private static async Task
+ >> FindAsync(TestDb fixture, FindGroupCandidatesQuery query)
+ {
+ await using var db = fixture.New();
+ return await new FindGroupCandidatesQueryHandler(
+ db,
+ new GroupFilterMatcher(db),
+ new GroupElementResolver(db)
+ ).Handle(query, CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task MarksWhatIsAlreadyInGroup_AndFiltersByUnitDuration()
+ {
+ var fixture = new TestDb();
+ var shortFilm = Playable("Короткий", 20);
+ var longFilm = Playable("Длинный", 120);
+ var inside = Playable("Уже внутри", 100);
+
+ var group = Group.Create("Кино");
+ group.SetFilter(new GroupFilter(UnitMinutesMin: 60).ToJson());
+ group.AddElement(GroupElementKind.Show, inside.Show.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.AddRange(shortFilm.Asset, longFilm.Asset, inside.Asset);
+ seed.Shows.AddRange(shortFilm.Show, longFilm.Show, inside.Show);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var result = await FindAsync(fixture, new FindGroupCandidatesQuery(group.Id));
+
+ Assert.True(result.IsSuccess);
+ // Короткий отсечён по длине единицы: правило меряет саму серию, а не сумму по шоу.
+ Assert.Equal(["Длинный", "Уже внутри"], result.Value.Select(c => c.ElementName));
+ Assert.False(result.Value.Single(c => c.ElementName == "Длинный").AlreadyInGroup);
+ Assert.True(result.Value.Single(c => c.ElementName == "Уже внутри").AlreadyInGroup);
+ }
+
+ [Fact]
+ public async Task UsesFilterFromQuery_WhenGivenExplicitly()
+ {
+ var fixture = new TestDb();
+ var film = Playable("Кино", 100);
+ // У группы своего правила нет — редактор крутит фильтр до сохранения.
+ var group = Group.Create("Черновик");
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.Add(film.Asset);
+ seed.Shows.Add(film.Show);
+ seed.Groups.Add(group);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ var withFilter = await FindAsync(
+ fixture,
+ new FindGroupCandidatesQuery(group.Id, new GroupFilter(ShowKinds: [ShowKind.Single]))
+ );
+ var withoutFilter = await FindAsync(fixture, new FindGroupCandidatesQuery(group.Id));
+
+ Assert.Single(withFilter.Value);
+ // Без правила подбирать не из чего — и это отказ, а не пустой список.
+ Assert.False(withoutFilter.IsSuccess);
+ Assert.Equal(GroupErrors.FilterNotSet, withoutFilter.Error);
+ }
+
+ [Fact]
+ public async Task Fails_WhenGroupIsGone()
+ {
+ var fixture = new TestDb();
+
+ var result = await FindAsync(fixture, new FindGroupCandidatesQuery(Guid.NewGuid()));
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal(GroupErrors.NotFound, result.Error);
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GetTemplateTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GetTemplateTests.cs
new file mode 100644
index 0000000..3b1e8d3
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/GetTemplateTests.cs
@@ -0,0 +1,198 @@
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Programming.Templates;
+using TeleWave.Application.Programming.Templates.GetTemplate;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Сетка глазами редактора: слои по приоритету, слоты с именами групп и признаки состояния —
+/// есть ли неприменённые правки и есть ли к чему откатываться.
+///
+public class GetTemplateTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public async Task ReturnsLayersByPriority_WithGroupNamesAndState()
+ {
+ var fixture = new TestDb();
+ var group = Group.Create("Кино");
+ var channel = Channel.Create("Первый", "first", T0);
+ channel.UpdateTimeSettings(1, 180, new TimeOnly(6, 0));
+
+ var template = ScheduleTemplate.Create(channel.Id, "Сетка");
+ channel.SetTemplate(template.Id);
+ var top = template.AddLayer("Выходные", 20);
+ var slot = top.AddSlot("Кино в прайм", new TimeOnly(20, 0), 120, Daypart.Prime, weekday: 6);
+ slot.UpdateContent(
+ new SlotContent(
+ slot.Title,
+ SlotKind.Content,
+ group.Id,
+ new SlotStrategy(SlotStrategyType.RandomWithCooldown, CooldownDays: 14).ToJson(),
+ null,
+ SlotBlockMode.Count,
+ 1,
+ OverflowPolicy.ContinueNext
+ )
+ );
+ template.MarkChanged();
+
+ await using (var seed = fixture.New())
+ {
+ seed.Groups.Add(group);
+ seed.Channels.Add(channel);
+ seed.ScheduleTemplates.Add(template);
+ 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.True(result.IsSuccess);
+ var dto = result.Value;
+
+ // Слои идут сверху вниз: первым тот, что перекрывает остальные.
+ Assert.Equal(["Выходные", "Фон"], dto.Layers.Select(l => l.Name));
+ Assert.Equal(new TimeOnly(6, 0), dto.DayStartTime);
+ Assert.Equal(180, dto.UtcOffsetMinutes);
+
+ var stored = Assert.Single(dto.Layers[0].Slots);
+ // Имя группы приезжает вместе со слотом: инспектор показывает его без второго запроса.
+ Assert.Equal("Кино", stored.GroupName);
+ Assert.Equal(6, stored.Weekday);
+ Assert.Equal(SlotStrategyType.RandomWithCooldown, stored.Strategy!.Type);
+ Assert.Equal(14, stored.Strategy.CooldownDays);
+
+ // Правка эфир не двигает — она поднимает ревизию, и плашка об этом видна в шапке канала.
+ Assert.True(dto.HasPendingChanges);
+ Assert.Equal(1, dto.Revision);
+ Assert.Equal(0, dto.AppliedRevision);
+ // Сетку ни разу не применяли — откатывать не к чему, и кнопки быть не должно.
+ Assert.False(dto.CanRestore);
+ }
+
+ [Fact]
+ public async Task CanRestore_AfterFirstApply()
+ {
+ var fixture = new TestDb();
+ var channel = Channel.Create("Первый", "first", T0);
+ var template = ScheduleTemplate.Create(channel.Id, "Сетка");
+ channel.SetTemplate(template.Id);
+ 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);
+ }
+
+ await using var db = fixture.New();
+ var result = await new GetChannelTemplateQueryHandler(db).Handle(
+ new GetChannelTemplateQuery(channel.Id),
+ CancellationToken.None
+ );
+
+ Assert.True(result.Value.CanRestore);
+ Assert.False(result.Value.HasPendingChanges);
+ }
+
+ [Fact]
+ public async Task Fails_WhenChannelOrTemplateIsMissing()
+ {
+ var fixture = new TestDb();
+ var channel = Channel.Create("Без сетки", "no-grid", T0);
+
+ await using (var seed = fixture.New())
+ {
+ seed.Channels.Add(channel);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var handler = new GetChannelTemplateQueryHandler(db);
+
+ var noChannel = await handler.Handle(
+ new GetChannelTemplateQuery(Guid.NewGuid()),
+ CancellationToken.None
+ );
+ var noTemplate = await handler.Handle(
+ new GetChannelTemplateQuery(channel.Id),
+ CancellationToken.None
+ );
+
+ Assert.False(noChannel.IsSuccess);
+ Assert.False(noTemplate.IsSuccess);
+ }
+
+ [Fact]
+ public async Task ElementResolver_ExpandsCollectionIntoItsParts()
+ {
+ // Коллекция в группе — одна позиция, но эфира она даёт столько же, сколько все её части.
+ var fixture = new TestDb();
+ var film = Show.Create("Часть 1", ShowKind.Single);
+ film.SetAudience(ShowAudience.Pg13);
+ var sequel = Show.Create("Часть 2", ShowKind.Single);
+ sequel.SetAudience(ShowAudience.R);
+ var assets = new List();
+
+ foreach (var show in new[] { film, sequel })
+ {
+ var asset = MediaAsset.Register($"{show.Name}.mkv", ".mkv", MediaSource.Upload);
+ asset.MarkProcessing();
+ asset.MarkReady(
+ new MediaReadyInfo(
+ TimeSpan.FromMinutes(90),
+ 6,
+ 900,
+ 1920,
+ 1080,
+ "h264",
+ "aac",
+ $"assets/{show.Name}"
+ )
+ );
+ assets.Add(asset);
+ show.AddEpisode(asset.Id);
+ }
+
+ var collection = Collection.Create("Франшиза");
+ collection.AddShow(film.Id);
+ collection.AddShow(sequel.Id);
+
+ await using (var seed = fixture.New())
+ {
+ seed.MediaAssets.AddRange(assets);
+ seed.Shows.AddRange(film, sequel);
+ seed.Collections.Add(collection);
+ await seed.SaveChangesAsync(CancellationToken.None);
+ }
+
+ await using var db = fixture.New();
+ var info = await new GroupElementResolver(db).ResolveAsync(
+ [(GroupElementKind.Collection, collection.Id), (GroupElementKind.Show, film.Id)],
+ CancellationToken.None
+ );
+
+ var franchise = info[(GroupElementKind.Collection, collection.Id)];
+ Assert.Equal("Франшиза", franchise.Name);
+ Assert.Equal(2, franchise.UnitCount);
+ Assert.Equal(TimeSpan.FromMinutes(180), franchise.TotalDuration);
+ // Рейтинг франшизы — самый строгий среди частей: по нему её отсеивают в детское время.
+ Assert.Equal(ShowAudience.R, franchise.Audience);
+
+ var single = info[(GroupElementKind.Show, film.Id)];
+ Assert.Equal(1, single.UnitCount);
+ Assert.Equal(ShowKind.Single, single.ShowKind);
+ }
+}
diff --git a/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs
new file mode 100644
index 0000000..58588c3
--- /dev/null
+++ b/backend/tests/TeleWave.Application.Tests/Programming/GridPlanPreviewTests.cs
@@ -0,0 +1,233 @@
+using TeleWave.Application.Programming.Groups;
+using TeleWave.Application.Programming.Templates;
+using TeleWave.Application.Programming.Templates.Generate;
+using TeleWave.Application.Tests.Support;
+using TeleWave.Domain.Broadcast;
+using TeleWave.Domain.Library;
+using TeleWave.Domain.Media;
+using TeleWave.Domain.Programming;
+using Xunit;
+
+namespace TeleWave.Application.Tests.Programming;
+
+///
+/// Витрина автосборки: каталог профилей, предпросмотр плана и его перевод в DTO. Предпросмотр —
+/// то, по чему админ принимает решение, поэтому проверяется отдельно от самой раскладки.
+///
+public class GridPlanPreviewTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ private static PlannedSlot Slot(
+ string title,
+ SlotKind kind,
+ SlotBlockMode blockMode,
+ int blockValue,
+ int? weekday = null
+ ) =>
+ new(
+ GridPlanLayer.Main,
+ weekday,
+ new TimeOnly(20, 0),
+ 120,
+ title,
+ Daypart.Prime,
+ kind,
+ kind == SlotKind.Content ? Guid.NewGuid() : null,
+ kind == SlotKind.Content ? title : null,
+ blockMode,
+ blockValue,
+ null,
+ null,
+ IsAnchor: false
+ );
+
+ [Fact]
+ public async Task Profiles_AreListed_WithReferenceAndDescription()
+ {
+ var result = await new ListGridProfilesQueryHandler().Handle(
+ new ListGridProfilesQuery(),
+ CancellationToken.None
+ );
+
+ Assert.True(result.IsSuccess);
+ // Каждый профиль должен объяснять себя: имя, на что похоже и для какой библиотеки годится.
+ Assert.Equal(GridProfiles.All.Count, result.Value.Count);
+ Assert.All(
+ result.Value,
+ profile =>
+ {
+ Assert.False(string.IsNullOrWhiteSpace(profile.Name));
+ Assert.False(string.IsNullOrWhiteSpace(profile.Reference));
+ Assert.False(string.IsNullOrWhiteSpace(profile.Description));
+ }
+ );
+ Assert.Contains(result.Value, p => p.Kind == GridProfileKind.Animation);
+ }
+
+ [Fact]
+ public void Mapper_DescribesEveryBlockKind()
+ {
+ var plan = new GridPlan(
+ GridProfiles.Get(GridProfileKind.Mixed),
+ GridGenerationMode.Rebuild,
+ [
+ Slot("Сериалы", SlotKind.Content, SlotBlockMode.Count, 3),
+ Slot("Кино", SlotKind.Content, SlotBlockMode.FillSlot, 1),
+ Slot("Полоса", SlotKind.Content, SlotBlockMode.Duration, 90, weekday: 6),
+ Slot("Повтор", SlotKind.Repeat, SlotBlockMode.FillSlot, 1),
+ Slot("Конец вещания", SlotKind.SignOff, SlotBlockMode.FillSlot, 1),
+ ],
+ ["замечание"],
+ SlotsToRemove: 4,
+ FallbackGroupId: Guid.NewGuid(),
+ FallbackGroupName: "Фон",
+ FreeMinutes: 10080,
+ CoveredMinutes: 5040
+ );
+
+ var dto = plan.ToDto();
+
+ Assert.Equal(GridProfileKind.Mixed, dto.Profile);
+ Assert.Equal(plan.Profile.Name, dto.ProfileName);
+ Assert.Equal(4, dto.SlotsToRemove);
+ Assert.Equal("Фон", dto.FallbackGroupName);
+ Assert.Equal(["замечание"], dto.Notes);
+
+ // Описание блока — то, что читает админ вместо режима и числа.
+ Assert.Equal(
+ ["3 подряд", "до конца слота", "90 мин", "повтор", "конец вещания"],
+ dto.Slots.Select(s => s.Block)
+ );
+ // Время приезжает строкой «ЧЧ:ММ»: в предпросмотре оно только показывается.
+ Assert.All(dto.Slots, slot => Assert.Equal("20:00", slot.Start));
+ Assert.Equal(6, dto.Slots[2].Weekday);
+ Assert.Null(dto.Slots[0].Weekday);
+ // У неконтентных слотов группы нет — показывать в колонке нечего.
+ Assert.Null(dto.Slots[3].GroupName);
+ Assert.Equal("Сериалы", dto.Slots[0].GroupName);
+ }
+
+ [Fact]
+ public async Task Preview_ReturnsPlanForProfile()
+ {
+ var fixture = new TestDb();
+ var channelId = await SeedAsync(fixture);
+
+ await using var db = fixture.New();
+ var result = await new PreviewGeneratedGridQueryHandler(
+ new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
+ ).Handle(
+ new PreviewGeneratedGridQuery(
+ channelId,
+ GridProfileKind.Kids,
+ GridGenerationMode.Rebuild
+ ),
+ CancellationToken.None
+ );
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(GridProfileKind.Kids, result.Value.Profile);
+ Assert.NotEmpty(result.Value.Slots);
+ // Пересборка сносит то, что было, и это видно до нажатия.
+ Assert.True(result.Value.SlotsToRemove >= 0);
+ Assert.Equal(7 * GridCoverage.MinutesInDay, result.Value.FreeMinutes);
+ // У детского профиля ночь — конец вещания, и в предпросмотре он подписан именно так.
+ Assert.Contains(result.Value.Slots, s => s.Block == "конец вещания");
+ }
+
+ [Fact]
+ public async Task Preview_FailsWhenChannelIsGone()
+ {
+ var fixture = new TestDb();
+ await using var db = fixture.New();
+
+ var result = await new PreviewGeneratedGridQueryHandler(
+ new GridPlanner(db, GroupServices.Dynamic(db), new GroupElementResolver(db))
+ ).Handle(
+ new PreviewGeneratedGridQuery(
+ Guid.NewGuid(),
+ GridProfileKind.Mixed,
+ GridGenerationMode.FillGaps
+ ),
+ CancellationToken.None
+ );
+
+ Assert.False(result.IsSuccess);
+ }
+
+ [Fact]
+ public void Validator_RejectsUnknownProfileAndMode()
+ {
+ var validator = new GenerateGridCommandValidator();
+
+ Assert.True(
+ validator
+ .Validate(
+ new GenerateGridCommand(
+ Guid.NewGuid(),
+ GridProfileKind.Sitcom,
+ GridGenerationMode.Rebuild
+ )
+ )
+ .IsValid
+ );
+ // Значения enum приходят с клиента строкой — за пределами каталога они бессмысленны.
+ Assert.False(
+ validator
+ .Validate(
+ new GenerateGridCommand(
+ Guid.NewGuid(),
+ (GridProfileKind)42,
+ GridGenerationMode.FillGaps
+ )
+ )
+ .IsValid
+ );
+ }
+
+ /// Канал с одной группой мягкого рейтинга — чтобы детскому профилю было чем закрыться.
+ private static async Task SeedAsync(TestDb fixture)
+ {
+ var show = Show.Create("Мультсериал", ShowKind.Series);
+ show.SetAudience(ShowAudience.G);
+ var assets = new List();
+
+ for (var i = 0; i < 20; i++)
+ {
+ var asset = MediaAsset.Register($"e{i}.mkv", ".mkv", MediaSource.Upload);
+ asset.MarkProcessing();
+ asset.MarkReady(
+ new MediaReadyInfo(
+ TimeSpan.FromMinutes(22),
+ 6,
+ 220,
+ 1920,
+ 1080,
+ "h264",
+ "aac",
+ $"assets/e{i}"
+ )
+ );
+ assets.Add(asset);
+ show.AddEpisode(asset.Id);
+ }
+
+ var group = Group.Create("Мультфильмы");
+ group.AddElement(GroupElementKind.Show, show.Id);
+
+ var channel = Channel.Create("Детский", "kids", T0);
+ var template = ScheduleTemplate.Create(channel.Id, "Сетка");
+ channel.SetTemplate(template.Id);
+
+ await using var seed = fixture.New();
+ seed.MediaAssets.AddRange(assets);
+ seed.Shows.Add(show);
+ seed.Groups.Add(group);
+ seed.Channels.Add(channel);
+ seed.ScheduleTemplates.Add(template);
+ await seed.SaveChangesAsync(CancellationToken.None);
+
+ return channel.Id;
+ }
+}