diff --git a/backend/tests/TeleWave.Application.Tests/Media/MovieImportHandlerTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MovieImportHandlerTests.cs new file mode 100644 index 0000000..c67db2f --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/MovieImportHandlerTests.cs @@ -0,0 +1,182 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Library.Genres; +using TeleWave.Application.Media.ManualInbox; +using TeleWave.Application.Media.MovieImport; +using TeleWave.Application.Metadata; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +/// +/// Пути разбора фильмов, где всё идёт не по-хорошему: источник не настроен, ассет пропал, файл уже +/// в чужом шоу. Каждый из них должен быть тихим отказом, а не исключением: разбор пакетный. +/// +public class MovieImportHandlerTests +{ + private const string Provider = "tmdb"; + + private static IMetadataProviderResolver EmptyResolver() + { + var resolver = Substitute.For(); + resolver.AvailableKeys.Returns([]); + // Явный null: без него NSubstitute подсунул бы автозаглушку провайдера, и «источник + // не настроен» перестало бы быть тем, что проверяет тест. + resolver.Resolve(Arg.Any()).Returns((IMetadataProvider?)null); + return resolver; + } + + private static ShowMetadataApplier Applier( + IAppDbContext db, + IMetadataProviderResolver resolver + ) => + new( + db, + resolver, + Substitute.For(), + Substitute.For(), + new GenreMatcher(db) + ); + + [Fact] + public async Task Match_ReturnsNothing_WhenProviderIsNotConfigured() + { + // Ключа источника нет — искать нечем, и это не ошибка: таблица разбора просто пуста. + var fixture = new TestDb(); + await using var db = fixture.New(); + var matcher = new MovieMatcher(db, EmptyResolver()); + + var rows = await new MatchMoviesQueryHandler(matcher).Handle( + new MatchMoviesQuery(["Gladiator.2000.mkv"], Provider), + CancellationToken.None + ); + + Assert.Empty(rows); + } + + [Fact] + public async Task MatchOne_ReturnsNotFound_WhenProviderIsNotConfigured() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + var matcher = new MovieMatcher(db, EmptyResolver()); + + var row = await new MatchMovieQueryHandler(matcher).Handle( + new MatchMovieQuery("file.mkv", "Гладиатор", 2000, Provider), + CancellationToken.None + ); + + Assert.Equal(MovieMatchStatus.NotFound, row.Status); + Assert.Equal("file.mkv", row.Name); + } + + [Fact] + public async Task Import_ReportsMissingAsset() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + var storage = Substitute.For(); + storage.ListManualInbox(Arg.Any()).Returns([]); + + var result = await new ImportMoviesCommandHandler( + db, + new ManualInboxTaker(db, storage), + Substitute.For(), + Applier(db, EmptyResolver()) + ).Handle( + new ImportMoviesCommand( + [new MovieImportItem(null, Guid.NewGuid(), "Пропавший", null, null)], + Provider + ), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value.Created); + Assert.Single(result.Value.Failed); + } + + [Fact] + public async Task AutoAttach_DoesNothing_WhenProviderIsNotConfigured() + { + var fixture = new TestDb(); + var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Inbox); + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var resolver = EmptyResolver(); + var result = await new AutoAttachMovieCommandHandler( + db, + resolver, + new MovieMatcher(db, resolver), + Applier(db, resolver) + ).Handle(new AutoAttachMovieCommand(asset.Id, "a.mkv"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Value); + } + + [Fact] + public async Task AutoAttach_FailsOnMissingAsset() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var provider = Substitute.For(); + var resolver = Substitute.For(); + resolver.AvailableKeys.Returns([Provider]); + resolver.Resolve(Provider).Returns(provider); + + var result = await new AutoAttachMovieCommandHandler( + db, + resolver, + new MovieMatcher(db, resolver), + Applier(db, resolver) + ).Handle(new AutoAttachMovieCommand(Guid.NewGuid(), "a.mkv"), CancellationToken.None); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task AutoAttach_SkipsAssetAlreadyInShow() + { + // Ассет мог уехать в шоу вручную, пока файл ждал очереди, — второе шоу по нему не заводим. + var fixture = new TestDb(); + var asset = MediaAsset.Register("a.mkv", ".mkv", MediaSource.Inbox); + var show = Show.Create("Уже есть", ShowKind.Single); + show.AddEpisode(asset.Id); + + await using (var seed = fixture.New()) + { + seed.MediaAssets.Add(asset); + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var provider = Substitute.For(); + var resolver = Substitute.For(); + resolver.AvailableKeys.Returns([Provider]); + resolver.Resolve(Provider).Returns(provider); + + var result = await new AutoAttachMovieCommandHandler( + db, + resolver, + new MovieMatcher(db, resolver), + Applier(db, resolver) + ).Handle(new AutoAttachMovieCommand(asset.Id, "a.mkv"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Value); + await provider + .DidNotReceive() + .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Media/MovieImportValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MovieImportValidatorTests.cs new file mode 100644 index 0000000..59bc820 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/MovieImportValidatorTests.cs @@ -0,0 +1,110 @@ +using TeleWave.Application.Media.MovieImport; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +/// +/// Валидаторы разбора фильмов. Проверяют вход до хендлера, поэтому важны обе стороны: пропустить +/// нормальную строку и не пропустить ту, что уронила бы разбор или ушла бы во внешний источник. +/// +public class MovieImportValidatorTests +{ + private static MovieImportItem FromManual(string? title = "Гладиатор") => + new("Gladiator.2000.mkv", null, title!, 2000, "98"); + + private static MovieImportItem Uploaded() => new(null, Guid.NewGuid(), "Гладиатор", 2000, null); + + [Fact] + public void Match_AcceptsNormalBatch() + { + var command = new MatchMoviesQuery(["a.mkv", "b.mkv"], "tmdb"); + + Assert.True(new MatchMoviesQueryValidator().Validate(command).IsValid); + } + + [Theory] + [InlineData(0)] // пустой список разбирать нечего + [InlineData(201)] // каждая строка — запрос во внешний источник, и пачка ограничена + public void Match_RejectsBadBatchSize(int count) + { + var names = Enumerable.Range(0, count).Select(i => $"{i}.mkv").ToList(); + + Assert.False( + new MatchMoviesQueryValidator().Validate(new MatchMoviesQuery(names, "tmdb")).IsValid + ); + } + + [Fact] + public void Match_RejectsMissingProvider() + { + Assert.False( + new MatchMoviesQueryValidator().Validate(new MatchMoviesQuery(["a.mkv"], "")).IsValid + ); + } + + [Theory] + [InlineData("Шоу Трумана", 1998, true)] + [InlineData("Шоу Трумана", null, true)] // год необязателен: в имени его часто нет + [InlineData("", 1998, false)] // искать нечего + [InlineData("Кино", 1800, false)] // раньше кинематографа + [InlineData("Кино", 2300, false)] + public void MatchOne_ChecksTitleAndYear(string title, int? year, bool valid) + { + var query = new MatchMovieQuery("file.mkv", title, year, "tmdb"); + + Assert.Equal(valid, new MatchMovieQueryValidator().Validate(query).IsValid); + } + + [Fact] + public void Import_AcceptsBothSources() + { + var validator = new ImportMoviesCommandValidator(); + + Assert.True(validator.Validate(new ImportMoviesCommand([FromManual()], "tmdb")).IsValid); + Assert.True(validator.Validate(new ImportMoviesCommand([Uploaded()], "tmdb")).IsValid); + } + + [Fact] + public void Import_RejectsItemWithoutFile() + { + // Без файла шоу пустое — заводить его незачем. + var item = new MovieImportItem(null, null, "Гладиатор", 2000, null); + + Assert.False( + new ImportMoviesCommandValidator() + .Validate(new ImportMoviesCommand([item], "tmdb")) + .IsValid + ); + } + + [Fact] + public void Import_RejectsItemWithTwoSources() + { + // Путь в manual/ и уже загруженный ассет разом: неясно, какой файл брать. + var item = new MovieImportItem("Gladiator.mkv", Guid.NewGuid(), "Гладиатор", 2000, null); + + Assert.False( + new ImportMoviesCommandValidator() + .Validate(new ImportMoviesCommand([item], "tmdb")) + .IsValid + ); + } + + [Fact] + public void Import_RejectsEmptyTitle() + { + Assert.False( + new ImportMoviesCommandValidator() + .Validate(new ImportMoviesCommand([FromManual(title: " ")], "tmdb")) + .IsValid + ); + } + + [Fact] + public void Import_RejectsEmptyBatch() + { + Assert.False( + new ImportMoviesCommandValidator().Validate(new ImportMoviesCommand([], "tmdb")).IsValid + ); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/JunctionBumperValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/JunctionBumperValidatorTests.cs new file mode 100644 index 0000000..9193dfb --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/JunctionBumperValidatorTests.cs @@ -0,0 +1,153 @@ +using TeleWave.Application.Broadcast.Bumpers; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Junctions; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Валидаторы стыков и заставок. Проверяют границы, за которыми настройка перестаёт что-либо +/// значить в эфире: врезка длиннее суток, допуск от часа шире самого часа, семь строк в кадре. +/// +public class JunctionBumperValidatorTests +{ + private static JunctionElementInput Element( + int amount = 1, + int choiceWeight = 1, + string? choiceKey = null, + JunctionConditions? conditions = null + ) => + new( + JunctionElementKind.Ad, + Guid.NewGuid(), + null, + null, + JunctionAmountMode.Count, + amount, + IsRequired: false, + choiceKey, + choiceWeight, + conditions + ); + + private static bool Valid(JunctionElementInput input) => + new UpdateJunctionElementCommandValidator() + .Validate(new UpdateJunctionElementCommand(Guid.NewGuid(), Guid.NewGuid(), input)) + .IsValid; + + [Fact] + public void Element_AcceptsUsualSettings() + { + var conditions = new JunctionConditions( + MinMinutesBetween: 30, + Chance: 40, + NearHourMinutes: 2 + ); + + Assert.True( + Valid(Element(amount: 3, choiceWeight: 5, choiceKey: "fork", conditions: conditions)) + ); + // Условий может не быть вовсе — тогда проверять нечего. + Assert.True(Valid(Element())); + } + + [Theory] + [InlineData(0)] // ноль врезок — это отсутствие врезки, а не настройка + [InlineData(24 * 60 + 1)] // длиннее вещательных суток + public void Element_RejectsAmountOutOfRange(int amount) + { + Assert.False(Valid(Element(amount))); + } + + [Theory] + [InlineData(-1)] + [InlineData(1001)] + public void Element_RejectsWeightOutOfRange(int weight) + { + Assert.False(Valid(Element(choiceWeight: weight))); + } + + [Theory] + [InlineData(101)] // вероятность выше ста + [InlineData(-1)] + public void Element_RejectsChanceOutOfRange(int chance) + { + Assert.False(Valid(Element(conditions: new JunctionConditions(Chance: chance)))); + } + + [Fact] + public void Element_RejectsNearHourWiderThanHalfHour() + { + // Допуск в 31 минуту покрывает час целиком — это уже не привязка к :00. + Assert.False(Valid(Element(conditions: new JunctionConditions(NearHourMinutes: 31)))); + Assert.False(Valid(Element(conditions: new JunctionConditions(NearHourMinutes: -1)))); + } + + [Fact] + public void Junction_ChecksNameAndCap() + { + var validator = new UpdateJunctionCommandValidator(); + var id = Guid.NewGuid(); + + Assert.True( + validator.Validate(new UpdateJunctionCommand(id, "Рекламный шов", 120)).IsValid + ); + // Потолок можно не задавать — тогда стык ограничен только якорем. + Assert.True(validator.Validate(new UpdateJunctionCommand(id, "Шов", null)).IsValid); + Assert.False(validator.Validate(new UpdateJunctionCommand(id, " ", 120)).IsValid); + Assert.False(validator.Validate(new UpdateJunctionCommand(id, "Шов", 0)).IsValid); + Assert.False( + validator.Validate(new UpdateJunctionCommand(id, "Шов", 24 * 60 * 60 + 1)).IsValid + ); + Assert.False( + new CreateJunctionCommandValidator().Validate(new CreateJunctionCommand("")).IsValid + ); + } + + private static bool ValidVariant(int weight, params string[] lines) => + new UpdateBumperVariantCommandValidator() + .Validate( + new UpdateBumperVariantCommand( + Guid.NewGuid(), + Guid.NewGuid(), + new BumperVariantInput( + "Подблок", + BumperTrigger.Both, + BumperBackground.Template, + weight, + [ + .. lines.Select(text => new BumperLineDto( + BumperLineStyle.Title, + BumperLineColor.Text, + text + )), + ] + ) + ) + ) + .IsValid; + + [Fact] + public void Variant_AcceptsUpToSixLines() + { + Assert.True(ValidVariant(1, "СЕЙЧАС", "{now.title}")); + Assert.True(ValidVariant(0, "1", "2", "3", "4", "5", "6")); + } + + [Fact] + public void Variant_RejectsSeventhLine() + { + // Больше шести строк в кадр не помещается ни при каком размере шрифта. + Assert.False(ValidVariant(1, "1", "2", "3", "4", "5", "6", "7")); + } + + [Fact] + public void Variant_RejectsEmptyLineAndBadWeight() + { + Assert.False(ValidVariant(1, " ")); + Assert.False(ValidVariant(1001, "СЕЙЧАС")); + Assert.False(ValidVariant(-1, "СЕЙЧАС")); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs new file mode 100644 index 0000000..181d78b --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/TransferGuardsTests.cs @@ -0,0 +1,118 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Programming.Templates.Generate; +using TeleWave.Application.Programming.Templates.Transfer; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Обмен конфигурацией там, где обмениваться нечем: нет канала, нет шаблона, нет источника +/// метаданных. Всё это штатные исходы — админ открыл экран на канале, который ещё не настроен. +/// +public class TransferGuardsTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static ExportGridQueryHandler Export(IAppDbContext db) => + new(db, GroupServices.Dynamic(db)); + + [Fact] + public async Task Export_FailsWithoutChannel() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await Export(db) + .Handle(new ExportGridQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task Export_FailsWithoutTemplate() + { + // Канал без сетки выгружать нечем — и это отдельная ошибка, а не пустой файл. + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "first", T0); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await Export(db) + .Handle(new ExportGridQuery(channel.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task Import_FailsWithoutTemplate() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var config = new GridConfig([new GridConfigLayer("Основная сетка", 10, [])]); + var result = await new ImportGridCommandHandler( + db, + new SlotWriter(db), + GroupServices.Stats(db) + ).Handle(new ImportGridCommand(Guid.NewGuid(), config, false), CancellationToken.None); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task Prompt_FailsWithoutChannel() + { + var fixture = new TestDb(); + await using var db = fixture.New(); + + var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle( + new BuildGridPromptQuery(Guid.NewGuid(), [], null), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task Prompt_SaysWhatIsMissing_OnEmptyLibrary() + { + // Пустая библиотека — не ошибка: запрос собирается, но честно говорит, что групп нет, + // иначе модель придумает их сама и вернёт сетку под несуществующий контент. + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "first", T0); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle( + new BuildGridPromptQuery(channel.Id, [], null), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value.Groups); + Assert.Equal(0, result.Value.Shows); + Assert.Contains("Готовых групп нет", result.Value.Prompt, StringComparison.Ordinal); + Assert.Contains("Библиотека пуста", result.Value.Prompt, StringComparison.Ordinal); + // Стыков и заставок тоже нет — модель должна знать, что ссылаться не на что. + Assert.Contains("стыков ещё нет", result.Value.Prompt, StringComparison.Ordinal); + } + + private static GroupCatalog Catalog(IAppDbContext db) => + new(db, GroupServices.Dynamic(db), new GroupElementResolver(db)); +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/TransferValidatorTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/TransferValidatorTests.cs new file mode 100644 index 0000000..4f3d98f --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/TransferValidatorTests.cs @@ -0,0 +1,123 @@ +using TeleWave.Application.Programming.Templates.Transfer; +using TeleWave.Domain.Programming; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Валидаторы обмена конфигурацией. Файл приходит извне — от другой установки или от языковой +/// модели, — поэтому проверка формата и объёма здесь единственная преграда между чужим JSON +/// и сеткой канала. +/// +public class TransferValidatorTests +{ + private static GridConfigSlot Slot() => + new("Вечерний блок", new TimeOnly(20, 0), 120, Daypart.Prime, Group: "Сериалы"); + + private static GridConfig Config(params GridConfigLayer[] layers) => new(layers); + + private static GridConfig Valid() => + Config(new GridConfigLayer("Основная сетка", 10, [Slot()])); + + [Fact] + public void Import_AcceptsNormalFile() + { + var command = new ImportGridCommand(Guid.NewGuid(), Valid(), Replace: true); + + Assert.True(new ImportGridCommandValidator().Validate(command).IsValid); + } + + [Fact] + public void Import_RejectsForeignFormat() + { + // Чужой формат с теми же полями разобрался бы во что-то бессмысленное — лучше отказать. + var config = Valid() with + { + Format = "some.other.format", + }; + + Assert.False(Validate(config)); + } + + [Fact] + public void Import_RejectsFutureVersion() + { + var config = Valid() with { Version = GridConfig.CurrentVersion + 1 }; + + Assert.False(Validate(config)); + } + + [Fact] + public void Import_RejectsEmptyAndNamelessLayers() + { + Assert.False(Validate(Config())); + Assert.False(Validate(Config(new GridConfigLayer(" ", 10, [Slot()])))); + } + + [Fact] + public void Import_RejectsOversizedFile() + { + // Потолки — про объём: недельная сетка это десятки слотов, а не десятки тысяч. + var layers = Enumerable + .Range(0, 65) + .Select(i => new GridConfigLayer($"Слой {i}", 10, [Slot()])) + .ToArray(); + + Assert.False(Validate(Config(layers))); + } + + [Fact] + public void Import_RejectsTooManyGroupsAndCollections() + { + var groups = Enumerable + .Range(0, 257) + .Select(i => new GridConfigGroup($"Группа {i}")) + .ToList(); + var collections = Enumerable + .Range(0, 257) + .Select(i => new GridConfigCollection($"Цикл {i}", ["Шоу"])) + .ToList(); + + Assert.False(Validate(Valid() with { Groups = groups })); + Assert.False(Validate(Valid() with { Collections = collections })); + } + + [Fact] + public void Prompt_AcceptsReferencesAndNotes() + { + var query = new BuildGridPromptQuery(Guid.NewGuid(), ["2×2"], "утром детское"); + + Assert.True(new BuildGridPromptQueryValidator().Validate(query).IsValid); + } + + [Fact] + public void Prompt_RejectsTooManyReferencesAndEmptyOnes() + { + var validator = new BuildGridPromptQueryValidator(); + var many = Enumerable.Range(0, 17).Select(i => $"Канал {i}").ToList(); + + Assert.False( + validator.Validate(new BuildGridPromptQuery(Guid.NewGuid(), many, null)).IsValid + ); + Assert.False( + validator.Validate(new BuildGridPromptQuery(Guid.NewGuid(), [" "], null)).IsValid + ); + } + + [Fact] + public void Prompt_RejectsHugeNotes() + { + var notes = new string('x', 4001); + + Assert.False( + new BuildGridPromptQueryValidator() + .Validate(new BuildGridPromptQuery(Guid.NewGuid(), [], notes)) + .IsValid + ); + } + + private static bool Validate(GridConfig config) => + new ImportGridCommandValidator() + .Validate(new ImportGridCommand(Guid.NewGuid(), config, Replace: false)) + .IsValid; +}