diff --git a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs index 6d4430a..5316a01 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/DebugExport/LibraryDebugCollector.cs @@ -430,6 +430,24 @@ public sealed class LibraryDebugCollector( /// Группа в дампе: готовые факты и числа, по которым сверяется её кэш статистики. private sealed record GroupSnapshot(Group Group, object Facts, int Units, int PlayableUnits); + /// + /// Строка вычисленного состава группы. Именованная запись, а не анонимный тип: сведения об + /// элементе берутся один раз, а не по разу на каждое поле. + /// + private sealed record CompositionEntry( + GroupElementKind Kind, + Guid Id, + string? Name, + int Position, + int Weight, + bool Pinned, + int? UnitCount, + int PlayableUnits, + double? Minutes, + ShowKind? ShowKind, + ShowAudience? Audience + ); + private async Task> GroupSnapshotsAsync( IReadOnlyList groups, IReadOnlyDictionary showNames, @@ -486,21 +504,24 @@ public sealed class LibraryDebugCollector( item.Weight, item.Role, }), - Composition = resolved.Select(element => new + Composition = resolved.Select(element => { - element.Kind, - element.Id, - Name = Name(element.Kind, element.Id), - element.Position, - element.Weight, - element.Pinned, - UnitCount = info.GetValueOrDefault((element.Kind, element.Id))?.UnitCount, - PlayableUnits = PlayableOf(element.Kind, element.Id), - Minutes = info.GetValueOrDefault((element.Kind, element.Id)) is { } resolvedInfo - ? Math.Round(resolvedInfo.TotalDuration.TotalMinutes, 1) - : (double?)null, - ShowKind = info.GetValueOrDefault((element.Kind, element.Id))?.ShowKind, - Audience = info.GetValueOrDefault((element.Kind, element.Id))?.Audience, + var resolvedInfo = info.GetValueOrDefault((element.Kind, element.Id)); + return new CompositionEntry( + element.Kind, + element.Id, + Name(element.Kind, element.Id), + element.Position, + element.Weight, + element.Pinned, + resolvedInfo?.UnitCount, + PlayableOf(element.Kind, element.Id), + resolvedInfo is null + ? null + : Math.Round(resolvedInfo.TotalDuration.TotalMinutes, 1), + resolvedInfo?.ShowKind, + resolvedInfo?.Audience + ); }), }; diff --git a/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs b/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs index eb931b2..5e10c15 100644 --- a/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs +++ b/backend/src/TeleWave.Infrastructure/Notifications/TelegramApi.cs @@ -24,6 +24,12 @@ public sealed class TelegramApi(ILogger logger) : ITelegramApi /// private const int LongPollSeconds = 25; + /// + /// Что просим у Telegram. Разговор ведут только команды и нажатия кнопок; всё остальное + /// (правки сообщений, участники чата) боту не нужно, а лишний тип обновления — лишний трафик. + /// + private static readonly string[] AllowedUpdates = ["message", "callback_query"]; + private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, @@ -51,7 +57,7 @@ public sealed class TelegramApi(ILogger logger) : ITelegramApi { offset, timeout = LongPollSeconds, - allowed_updates = new[] { "message", "callback_query" }, + allowed_updates = AllowedUpdates, }; var result = await CallAsync( @@ -178,7 +184,7 @@ public sealed class TelegramApi(ILogger logger) : ITelegramApi { url = settings.WebhookUrl, secret_token = settings.WebhookSecret, - allowed_updates = new[] { "message", "callback_query" }, + allowed_updates = AllowedUpdates, }, cancellationToken ); diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramAdminTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramAdminTests.cs new file mode 100644 index 0000000..87b9585 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramAdminTests.cs @@ -0,0 +1,175 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Notifications.Link; +using TeleWave.Application.Notifications.Settings; +using TeleWave.Application.Notifications.Subscribers; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Notifications; +using Xunit; + +namespace TeleWave.Application.Tests.Notifications; + +/// +/// Что видит и делает админ вокруг бота: выдача ссылки зрителю и список подписчиков. Ссылка — +/// единственная дверь в подписку, поэтому проверяем, что старые коды при выдаче новой гаснут. +/// +public class TelegramAdminTests +{ + private static readonly DateTimeOffset T0 = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Link_BurnsPreviousCodesOfTheSameUser() + { + var fixture = new TestDb(); + var userId = Guid.NewGuid(); + var stale = TelegramLinkCode.Issue("old", userId, T0); + var foreign = TelegramLinkCode.Issue("alien", Guid.NewGuid(), T0); + + await using (var seed = fixture.New()) + { + seed.TelegramSettings.Add(Configured()); + seed.TelegramLinkCodes.AddRange(stale, foreign); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new IssueTelegramLinkCommandHandler( + db, + new TelegramSettingsService(db), + CurrentUser(userId) + ).Handle(new IssueTelegramLinkCommand(), CancellationToken.None); + await db.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.StartsWith("https://t.me/telewave_bot?start=", result.Value.Url); + + await using var check = fixture.New(); + var codes = check.TelegramLinkCodes.ToList(); + // Своя старая ссылка гасится — две живые двери в один аккаунт не нужны; чужая не трогается. + Assert.NotNull(codes.Single(c => c.Code == "old").UsedAt); + Assert.Null(codes.Single(c => c.Code == "alien").UsedAt); + var issued = codes.Single(c => c.UserId == userId && c.UsedAt is null); + Assert.EndsWith(issued.Code, result.Value.Url); + Assert.Equal(TelegramLinkCode.Lifetime, issued.ExpiresAt - issued.CreatedAt); + } + + [Fact] + public async Task Link_WithoutConfiguredBot_IsRefused() + { + var fixture = new TestDb(); + + await using var db = fixture.New(); + var result = await new IssueTelegramLinkCommandHandler( + db, + new TelegramSettingsService(db), + CurrentUser(Guid.NewGuid()) + ).Handle(new IssueTelegramLinkCommand(), CancellationToken.None); + + // Ссылку не из чего собрать: имя бота приходит только после успешной проверки токена. + Assert.False(result.IsSuccess); + Assert.Equal("Telegram.NotConfigured", result.Error.Code); + Assert.Empty(db.TelegramLinkCodes); + } + + [Fact] + public async Task Link_WithoutSignedInViewer_IsRefused() + { + var fixture = new TestDb(); + + await using (var seed = fixture.New()) + { + seed.TelegramSettings.Add(Configured()); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var anonymous = Substitute.For(); + anonymous.UserId.Returns((Guid?)null); + + var result = await new IssueTelegramLinkCommandHandler( + db, + new TelegramSettingsService(db), + anonymous + ).Handle(new IssueTelegramLinkCommand(), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Empty(db.TelegramLinkCodes); + } + + [Fact] + public async Task Subscribers_ShowChannelsAndUserNamesNewestFirst() + { + var fixture = new TestDb(); + var channel = Channel.Create("Мультреалити", "mult", T0); + var userId = Guid.NewGuid(); + + var active = TelegramSubscriber.Create(100, userId, "viewer", T0); + active.Touch(T0.AddHours(2)); + active.Toggle(channel.Id, TelegramNotificationKind.ShowStart); + active.Toggle(channel.Id, TelegramNotificationKind.UpNext); + // Канал удалили, а подписка осталась висеть — это должно быть видно, а не пропасть. + active.Toggle(Guid.NewGuid(), TelegramNotificationKind.ShowStart); + + var older = TelegramSubscriber.Create(101, Guid.NewGuid(), "gone", T0.AddHours(-1)); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.TelegramSubscribers.AddRange(active, older); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var identity = Substitute.For(); + identity + .GetProfileAsync(userId, Arg.Any()) + .Returns(new CurrentUserProfile(userId, "leonid", "user", false)); + + await using var db = fixture.New(); + var result = await new ListTelegramSubscribersQueryHandler(db, identity).Handle( + new ListTelegramSubscribersQuery(), + CancellationToken.None + ); + + var subscribers = result.Value; + Assert.Equal([100L, 101L], subscribers.Select(s => s.ChatId).ToArray()); + Assert.Equal("leonid", subscribers[0].UserName); + // Учётку без профиля показываем как есть: чат жив, а пользователя уже нет. + Assert.Null(subscribers[1].UserName); + + var live = subscribers[0].Channels.Single(c => c.ChannelId == channel.Id); + Assert.Equal("Мультреалити", live.ChannelName); + Assert.Equal(["ShowStart", "UpNext"], live.Kinds); + Assert.Equal( + "—", + subscribers[0].Channels.Single(c => c.ChannelId != channel.Id).ChannelName + ); + } + + private static TelegramSettings Configured() + { + var settings = TelegramSettings.CreateDefault(); + settings.Update( + new TelegramSettingsUpdate( + true, + TelegramTransport.Polling, + "123:ABC", + null, + TelegramProxyKind.None, + null, + null, + null, + null + ) + ); + settings.SetBotUsername("telewave_bot"); + return settings; + } + + private static ICurrentUser CurrentUser(Guid userId) + { + var currentUser = Substitute.For(); + currentUser.UserId.Returns(userId); + return currentUser; + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs index e7d090c..364518b 100644 --- a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs @@ -77,7 +77,9 @@ public class TelegramBotTests .SendMessageAsync( Arg.Any(), 100, - Arg.Is(s => s.Contains("код")), + // Предикат NSubstitute принимает аргумент как допускающий null — само сообщение + // объявлено обязательным, потому и разыменовываем без проверки. + Arg.Is(s => s!.Contains("код")), null, Arg.Any() ); diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs index fa2bcdb..39e0b51 100644 --- a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs @@ -136,7 +136,9 @@ public class TelegramNotifierTests .SendMessageAsync( Arg.Any(), 100, - Arg.Is(s => s.Contains("Футурама")), + // Предикат NSubstitute принимает аргумент как допускающий null — само сообщение + // объявлено обязательным, потому и разыменовываем без проверки. + Arg.Is(s => s!.Contains("Футурама")), null, Arg.Any() ); diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramSettingsTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramSettingsTests.cs new file mode 100644 index 0000000..ef9bdaf --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramSettingsTests.cs @@ -0,0 +1,294 @@ +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Notifications; +using TeleWave.Application.Notifications.Settings; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Notifications; +using TeleWave.Infrastructure.Persistence; +using Xunit; + +namespace TeleWave.Application.Tests.Notifications; + +/// +/// Настройки бота: сохранение проверяет связь делом и приводит режим доставки в соответствие. +/// Главное свойство — админка никогда не видит секретов, только признак «задан». +/// +public class TelegramSettingsTests +{ + private const string Token = "123:ABC"; + + [Fact] + public async Task Update_WithPolling_RemovesWebhookAndRemembersBotName() + { + var fixture = new TestDb(); + var api = Api("telewave_bot"); + + await using var db = fixture.New(); + var result = await Handler(db, api).Handle(Command(token: Token), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("telewave_bot", result.Value.BotUsername); + Assert.Equal("https://t.me/telewave_bot", result.Value.BotLink); + Assert.True(result.Value.HasToken); + Assert.NotNull(result.Value.LastContactAt); + Assert.Null(result.Value.LastError); + // Пока стоит вебхук, Telegram не отдаёт обновления опросом — снимаем его явно. + await api.Received(1) + .DeleteWebhookAsync(Arg.Any(), Arg.Any()); + await api.DidNotReceive() + .SetWebhookAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Update_WithWebhook_GeneratesSecretOnce() + { + var fixture = new TestDb(); + var api = Api("telewave_bot"); + + await using var db = fixture.New(); + var handler = Handler(db, api); + var command = Command( + token: Token, + transport: TelegramTransport.Webhook, + webhookUrl: "https://tv.example.com/api/telegram/webhook" + ); + + Assert.True((await handler.Handle(command, CancellationToken.None)).IsSuccess); + await db.SaveChangesAsync(CancellationToken.None); + var secret = db.TelegramSettings.Single().WebhookSecret; + Assert.False(string.IsNullOrWhiteSpace(secret)); + + // Повторное сохранение секрет не меняет: он уже стоит на стороне Telegram, и смена + // на каждое нажатие «Сохранить» рвала бы приём обновлений до следующего setWebhook. + Assert.True((await handler.Handle(command, CancellationToken.None)).IsSuccess); + Assert.Equal(secret, db.TelegramSettings.Single().WebhookSecret); + await api.Received(2) + .SetWebhookAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Update_WhenTelegramFails_KeepsWhatAdminTypedAndReportsWhy() + { + var fixture = new TestDb(); + var api = Substitute.For(); + api.GetMeAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new InvalidOperationException("Unauthorized")); + + await using (var db = fixture.New()) + { + var result = await Handler(db, api) + .Handle(Command(token: Token, proxyHost: "127.0.0.1"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Telegram.Unreachable", result.Error.Code); + } + + // Настройки при этом сохранены: иначе админ набирал бы форму заново из-за одной опечатки. + await using var check = fixture.New(); + var stored = check.TelegramSettings.Single(); + Assert.Equal("127.0.0.1", stored.ProxyHost); + Assert.Equal("Unauthorized", stored.LastError); + } + + [Fact] + public async Task Update_WithoutToken_DoesNotEnableTheBot() + { + var fixture = new TestDb(); + var api = Api("telewave_bot"); + + await using var db = fixture.New(); + var result = await Handler(db, api).Handle(Command(token: null), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Telegram.NotConfigured", result.Error.Code); + await api.DidNotReceive() + .GetMeAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData( + TelegramTransport.Webhook, + null, + TelegramProxyKind.None, + null, + "Telegram.WebhookUrlRequired" + )] + [InlineData( + TelegramTransport.Polling, + null, + TelegramProxyKind.Socks5, + null, + "Telegram.ProxyHostRequired" + )] + public async Task Update_WithHalfFilledMode_IsRejectedBeforeAnyCall( + TelegramTransport transport, + string? webhookUrl, + TelegramProxyKind proxyKind, + string? proxyHost, + string expected + ) + { + var fixture = new TestDb(); + var api = Api("telewave_bot"); + + await using var db = fixture.New(); + var result = await Handler(db, api) + .Handle( + Command( + token: Token, + transport: transport, + webhookUrl: webhookUrl, + proxyKind: proxyKind, + proxyHost: proxyHost + ), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(expected, result.Error.Code); + await api.DidNotReceive() + .GetMeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Update_WhileDisabled_SavesWithoutTouchingTelegram() + { + var fixture = new TestDb(); + var api = Api("telewave_bot"); + + await using var db = fixture.New(); + var result = await Handler(db, api) + .Handle(Command(token: Token, isEnabled: false), CancellationToken.None); + + // Выключенный бот в сеть не ходит: проверять нечего, а лишний запрос через прокси + // ждал бы таймаута на каждом сохранении. + Assert.True(result.IsSuccess); + Assert.False(result.Value.IsEnabled); + Assert.Null(result.Value.BotLink); + await api.DidNotReceive() + .GetMeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Get_WithoutRow_ReturnsDefaultsAndCreatesNothing() + { + var fixture = new TestDb(); + + await using var db = fixture.New(); + var result = await new GetTelegramSettingsQueryHandler( + new TelegramSettingsService(db) + ).Handle(new GetTelegramSettingsQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Value.IsEnabled); + Assert.False(result.Value.HasToken); + Assert.Equal(TelegramTransport.Polling, result.Value.Transport); + Assert.Equal(0, result.Value.Subscribers); + // Чтение не должно заводить строку: GET в базу не пишет. + Assert.Empty(db.TelegramSettings); + } + + [Fact] + public async Task Get_CountsLiveSubscribersAndHidesSecrets() + { + var fixture = new TestDb(); + var settings = TelegramSettings.CreateDefault(); + settings.Update( + new TelegramSettingsUpdate( + true, + TelegramTransport.Polling, + Token, + null, + TelegramProxyKind.Socks5, + "127.0.0.1", + 1080, + "user", + "secret" + ) + ); + settings.SetBotUsername("telewave_bot"); + + var live = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", DateTimeOffset.UtcNow); + var stopped = TelegramSubscriber.Create(101, Guid.NewGuid(), "gone", DateTimeOffset.UtcNow); + stopped.Stop(); + + await using (var seed = fixture.New()) + { + seed.TelegramSettings.Add(settings); + seed.TelegramSubscribers.AddRange(live, stopped); + await seed.SaveChangesAsync(CancellationToken.None); + } + + await using var db = fixture.New(); + var result = await new GetTelegramSettingsQueryHandler( + new TelegramSettingsService(db) + ).Handle(new GetTelegramSettingsQuery(), CancellationToken.None); + + var dto = result.Value; + // Ушедший в «стоп» в счётчик не идёт: админ смотрит, скольким реально уходит рассылка. + Assert.Equal(1, dto.Subscribers); + Assert.True(dto.HasToken); + Assert.True(dto.HasProxyPassword); + Assert.Equal("user", dto.ProxyUsername); + Assert.Equal(1080, dto.ProxyPort); + } + + [Fact] + public void BotLink_NeedsBothNameAndEnabledFlag() + { + var settings = TelegramSettings.CreateDefault(); + Assert.Null(TelegramSettingsService.BotLink(settings)); + + settings.SetBotUsername("telewave_bot"); + // Имя есть, но бот выключен — кнопки у зрителя быть не должно. + Assert.Null(TelegramSettingsService.BotLink(settings)); + + settings.Update( + new TelegramSettingsUpdate( + true, + TelegramTransport.Polling, + Token, + null, + TelegramProxyKind.None, + null, + null, + null, + null + ) + ); + Assert.Equal("https://t.me/telewave_bot", TelegramSettingsService.BotLink(settings)); + } + + private static ITelegramApi Api(string username) + { + var api = Substitute.For(); + api.GetMeAsync(Arg.Any(), Arg.Any()) + .Returns(new TelegramBotInfo(1, username)); + return api; + } + + private static UpdateTelegramSettingsCommandHandler Handler( + AppDbContext db, + ITelegramApi api + ) => new(db, new TelegramSettingsService(db), api); + + private static UpdateTelegramSettingsCommand Command( + string? token, + bool isEnabled = true, + TelegramTransport transport = TelegramTransport.Polling, + string? webhookUrl = null, + TelegramProxyKind proxyKind = TelegramProxyKind.None, + string? proxyHost = null + ) => + new( + isEnabled, + transport, + token, + webhookUrl, + proxyKind, + proxyHost, + proxyHost is null ? null : 1080, + null, + null + ); +} diff --git a/backend/tests/TeleWave.Application.Tests/Programming/DebugExportTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/DebugExportTests.cs new file mode 100644 index 0000000..b02cc73 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Programming/DebugExportTests.cs @@ -0,0 +1,264 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using NSubstitute; +using TeleWave.Application.Broadcast.Scheduling; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Programming.Groups; +using TeleWave.Application.Programming.Planning; +using TeleWave.Application.Programming.Planning.DebugExport; +using TeleWave.Application.Programming.Templates; +using TeleWave.Application.Streaming; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Broadcast.Scheduling; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Domain.Programming; +using TeleWave.Domain.Programming.Planning; +using TeleWave.Infrastructure.Persistence; +using Xunit; + +namespace TeleWave.Application.Tests.Programming; + +/// +/// Отладочный дамп канала. Его собирают, когда эфир выглядит не так, как ждали, — значит он сам +/// обязан работать при любой библиотеке, включая сломанную: упавший ассет, серия без файла, +/// пустая группа. Ровно это здесь и проверяется, вместе с самодиагностикой в issues.json. +/// +public class DebugExportTests +{ + private static readonly DateTimeOffset T0 = new(2026, 4, 6, 6, 0, 0, TimeSpan.Zero); + + private sealed class FirstAlways : IRandomSource + { + public int Next(int maxExclusive) => 0; + } + + [Fact] + public async Task Export_PacksChannelAndLibraryIntoOneArchive() + { + var fixture = await SeedAsync(); + var store = Substitute.For(); + store + .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("/debug/dump.zip"); + + await using var db = fixture.New(); + var channelId = db.Channels.Single().Id; + var result = await Handler(db, store) + .Handle(new ExportChannelDebugCommand(channelId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.StartsWith("telewave-debug-one-", result.Value.FileName); + Assert.Equal("/debug/dump.zip", result.Value.SavedTo); + + var files = Unpack(result.Value.Content); + // Дамп отвечает на два разных вопроса: «что построилось» и «из чего было строить». + Assert.Contains("README.md", files.Keys); + Assert.Contains("summary.json", files.Keys); + Assert.Contains("drift.json", files.Keys); + Assert.Contains("effective-grid.json", files.Keys); + Assert.Contains("plan-preview.json", files.Keys); + Assert.Contains("library-shows.json", files.Keys); + Assert.Contains("library-interstitials.json", files.Keys); + Assert.Contains("groups-composition.json", files.Keys); + Assert.Contains("issues.json", files.Keys); + } + + [Fact] + public async Task Export_IssuesListEverythingThatKeepsShowsOffAir() + { + var fixture = await SeedAsync(); + var store = Substitute.For(); + + await using var db = fixture.New(); + var result = await Handler(db, store) + .Handle(new ExportChannelDebugCommand(db.Channels.Single().Id), CancellationToken.None); + + var issues = JsonDocument.Parse(Unpack(result.Value.Content)["issues.json"]).RootElement; + + var failed = Assert.Single(issues.GetProperty("failedEpisodes").EnumerateArray()); + Assert.Equal("broken.mkv", failed.GetProperty("file").GetString()); + Assert.Equal("Таймаут ffmpeg", failed.GetProperty("errorMessage").GetString()); + + // Серия есть, ассета нет вовсе — удаление в обход админки: в эфир не идёт и молчит. + var orphan = Assert.Single(issues.GetProperty("orphanEpisodes").EnumerateArray()); + Assert.Equal("Симпсоны", orphan.GetProperty("show").GetString()); + + var empty = Assert.Single(issues.GetProperty("emptyGroups").EnumerateArray()); + Assert.Equal("Пустая", empty.GetProperty("name").GetString()); + + // Кэш статистики группы никто не пересчитывал — цифры в её карточке врут, и это видно. + var stale = Assert.Single(issues.GetProperty("staleGroupStats").EnumerateArray()); + Assert.Equal("Мультсериалы", stale.GetProperty("name").GetString()); + Assert.Equal(0, stale.GetProperty("cachedUnits").GetInt32()); + Assert.Equal(3, stale.GetProperty("actualUnits").GetInt32()); + // Играбельных меньше, чем заведено: одна серия упала, ещё одна осталась без файла. + Assert.Equal(1, stale.GetProperty("playableUnits").GetInt32()); + } + + [Fact] + public async Task Export_ShowsPlayableUnitsBesideDeclaredOnes() + { + var fixture = await SeedAsync(); + var store = Substitute.For(); + + await using var db = fixture.New(); + var result = await Handler(db, store) + .Handle(new ExportChannelDebugCommand(db.Channels.Single().Id), CancellationToken.None); + + var groups = JsonDocument + .Parse(Unpack(result.Value.Content)["groups-composition.json"]) + .RootElement; + var content = groups + .EnumerateArray() + .Single(g => g.GetProperty("name").GetString() == "Мультсериалы"); + var element = Assert.Single(content.GetProperty("composition").EnumerateArray()); + + Assert.Equal("Симпсоны", element.GetProperty("name").GetString()); + Assert.Equal(3, element.GetProperty("unitCount").GetInt32()); + // Планировщик видит только готовые серии — расхождение с unitCount и есть ответ + // на «почему в эфир пошло меньше, чем заведено». + Assert.Equal(1, element.GetProperty("playableUnits").GetInt32()); + } + + [Fact] + public async Task Export_WithoutChannelOrTemplate_Fails() + { + var fixture = await SeedAsync(); + var store = Substitute.For(); + + await using var db = fixture.New(); + var missing = await Handler(db, store) + .Handle(new ExportChannelDebugCommand(Guid.NewGuid()), CancellationToken.None); + Assert.False(missing.IsSuccess); + + var channel = db.Channels.Single(); + channel.SetTemplate(null); + await db.SaveChangesAsync(CancellationToken.None); + + // Канал без шаблона собирать нечего: дамп строится вокруг сетки. + var untemplated = await Handler(db, store) + .Handle(new ExportChannelDebugCommand(channel.Id), CancellationToken.None); + Assert.False(untemplated.IsSuccess); + } + + private static ExportChannelDebugCommandHandler Handler( + AppDbContext db, + IDebugExportStore store + ) + { + var expander = new GroupExpander(db, GroupServices.Dynamic(db)); + var generator = new GridScheduleGenerator( + db, + expander, + new BumperResolver(db, Substitute.For(), new FirstAlways()), + new PostCheckRunner(db), + new FirstAlways(), + Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }), + Options.Create(new StreamingOptions()) + ); + + return new ExportChannelDebugCommandHandler( + db, + new ChannelDebugCollector( + db, + expander, + generator, + Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }) + ), + new LibraryDebugCollector(db, GroupServices.Dynamic(db), new GroupElementResolver(db)), + store + ); + } + + /// + /// Канал со сеткой и намеренно битой библиотекой: из трёх серий играбельна одна, вторая + /// упала на нарезке, у третьей нет файла. Плюс пустая группа — дыра в сетке. + /// + private static async Task SeedAsync() + { + var fixture = new TestDb(); + var channel = Channel.Create("Первый", "one", T0); + + var show = Show.Create("Симпсоны", ShowKind.Series); + var ready = ReadyAsset("s01e01.mkv", TimeSpan.FromMinutes(20)); + var broken = MediaAsset.Register("broken.mkv", ".mkv", MediaSource.Upload); + broken.MarkProcessing(); + broken.MarkFailed("Таймаут ffmpeg"); + show.AddEpisode(ready.Id); + show.AddEpisode(broken.Id); + show.AddEpisode(Guid.NewGuid()); + + var spot = Show.Create("Ролик", ShowKind.Interstitial); + var spotAsset = ReadyAsset("spot.mkv", TimeSpan.FromSeconds(30)); + spot.AddEpisode(spotAsset.Id); + + var content = Group.Create("Мультсериалы"); + content.AddElement(GroupElementKind.Show, show.Id); + var empty = Group.Create("Пустая"); + + var template = ScheduleTemplate.Create(channel.Id, "Сетка"); + var slot = template.Background!.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60); + slot.UpdateTiming(null, new TimeOnly(6, 0), 24 * 60, Daypart.Day, false, 30, null); + slot.UpdateContent( + new SlotContent( + slot.Title, + SlotKind.Content, + content.Id, + new SlotStrategy(SlotStrategyType.Sequential).ToJson(), + null, + SlotBlockMode.Count, + 1, + OverflowPolicy.ContinueNext, + null + ) + ); + channel.SetTemplate(template.Id); + + await using var seed = fixture.New(); + seed.Channels.Add(channel); + seed.Shows.AddRange(show, spot); + seed.MediaAssets.AddRange(ready, broken, spotAsset); + seed.Groups.AddRange(content, empty); + seed.ScheduleTemplates.Add(template); + await seed.SaveChangesAsync(CancellationToken.None); + + return fixture; + } + + private static MediaAsset ReadyAsset(string name, TimeSpan duration) + { + var asset = MediaAsset.Register(name, ".mkv", MediaSource.Upload); + asset.MarkProcessing(); + asset.MarkReady( + new MediaReadyInfo( + duration, + 2, + (int)(duration.TotalSeconds / 2), + 1920, + 1080, + "h264", + "aac", + "assets/x" + ) + ); + return asset; + } + + private static Dictionary Unpack(byte[] content) + { + using var buffer = new MemoryStream(content); + using var archive = new ZipArchive(buffer, ZipArchiveMode.Read); + return archive.Entries.ToDictionary( + entry => entry.FullName, + entry => + { + using var reader = new StreamReader(entry.Open(), Encoding.UTF8); + return reader.ReadToEnd(); + } + ); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Streaming/IptvGuideTests.cs b/backend/tests/TeleWave.Application.Tests/Streaming/IptvGuideTests.cs new file mode 100644 index 0000000..c99e77e --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Streaming/IptvGuideTests.cs @@ -0,0 +1,209 @@ +using TeleWave.Application.Streaming.GetIptvGuide; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Broadcast; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using Xunit; + +namespace TeleWave.Application.Tests.Streaming; + +/// +/// Телегид для IPTV: программа всех включённых каналов одной выгрузкой. Проверяем то, чем он +/// отличается от списка записей ленты — склейку серий в блок, отбор окна и подстановку описаний. +/// +public class IptvGuideTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Guide_MergesConsecutiveEpisodesOfTheSameShow() + { + var fixture = new TestDb(); + var channel = Enabled("Первый", "one"); + var show = Show.Create("Футурама", ShowKind.Series); + var first = MediaAsset.Register("s01e01.mkv", ".mkv", MediaSource.Upload); + var second = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload); + show.AddEpisode(first.Id); + show.AddEpisode(second.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.Add(show); + seed.MediaAssets.AddRange(first, second); + seed.ScheduleEntries.AddRange( + Program(channel.Id, first.Id, show.Id, T0, 25), + Program(channel.Id, second.Id, show.Id, T0.AddMinutes(25), 25) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3)); + + // Две серии встык — одна передача в глазах зрителя: гид показывает программу, а не файлы. + var programme = Assert.Single(Assert.Single(channels).Programmes); + Assert.Equal("Футурама", programme.Title); + Assert.Equal(T0, programme.StartsAtUtc); + Assert.Equal(T0.AddMinutes(50), programme.EndsAtUtc); + } + + [Fact] + public async Task Guide_KeepsBlocksApartWhenSomethingRanBetween() + { + var fixture = new TestDb(); + var channel = Enabled("Первый", "one"); + var show = Show.Create("Футурама", ShowKind.Series); + var first = MediaAsset.Register("s01e01.mkv", ".mkv", MediaSource.Upload); + var second = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload); + show.AddEpisode(first.Id); + show.AddEpisode(second.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.Add(show); + seed.MediaAssets.AddRange(first, second); + seed.ScheduleEntries.AddRange( + Program(channel.Id, first.Id, show.Id, T0, 25), + // Разрыв в пять минут — между сериями шла реклама, и одной передачей это не назвать. + Program(channel.Id, second.Id, show.Id, T0.AddMinutes(30), 25) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3)); + + var programmes = Assert.Single(channels).Programmes; + Assert.Equal(2, programmes.Count); + Assert.Equal(T0.AddMinutes(25), programmes[0].EndsAtUtc); + Assert.Equal(T0.AddMinutes(30), programmes[1].StartsAtUtc); + } + + [Fact] + public async Task Guide_TakesTitleAndCoverFromEpisodeThenFallsBackToShow() + { + var fixture = new TestDb(); + var channel = Enabled("Первый", "one"); + var stillId = Guid.NewGuid(); + var posterId = Guid.NewGuid(); + + var titled = Show.Create("Футурама", ShowKind.Series); + titled.SetPosterImage(posterId); + var withMetadata = MediaAsset.Register("s01e01.mkv", ".mkv", MediaSource.Upload); + var bare = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload); + titled + .AddEpisode(withMetadata.Id) + .ApplyMetadata("Космический пилот", "Фрай", stillId, null); + titled.AddEpisode(bare.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.Add(channel); + seed.Shows.Add(titled); + seed.MediaAssets.AddRange(withMetadata, bare); + seed.ScheduleEntries.AddRange( + Program(channel.Id, withMetadata.Id, titled.Id, T0, 25), + Program(channel.Id, bare.Id, titled.Id, T0.AddMinutes(30), 25) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3)); + + var programmes = Assert.Single(channels).Programmes; + Assert.Equal("Космический пилот", programmes[0].SubTitle); + Assert.Equal("Фрай", programmes[0].Description); + Assert.Equal(stillId, programmes[0].ImageId); + // Серия без метаданных показывается обложкой шоу — пустая карточка в плеере хуже общей. + Assert.Null(programmes[1].SubTitle); + Assert.Equal(posterId, programmes[1].ImageId); + } + + [Fact] + public async Task Guide_TakesOnlyEnabledChannelsAndTheRequestedWindow() + { + var fixture = new TestDb(); + var visible = Enabled("Первый", "one"); + var hidden = Channel.Create("Выключенный", "off", T0); + hidden.UpdateSettings("Выключенный", isEnabled: false, null); + + var show = Show.Create("Ворон", ShowKind.Single); + var asset = MediaAsset.Register("crow.mkv", ".mkv", MediaSource.Upload); + show.AddEpisode(asset.Id); + + await using (var seed = fixture.New()) + { + seed.Channels.AddRange(visible, hidden); + seed.Shows.Add(show); + seed.MediaAssets.Add(asset); + seed.ScheduleEntries.AddRange( + Program(visible.Id, asset.Id, show.Id, T0, 60), + // За окном — не в выгрузке; выключенный канал не показываем вовсе. + Program(visible.Id, asset.Id, show.Id, T0.AddHours(10), 60), + Program(hidden.Id, asset.Id, show.Id, T0, 60), + // Заставки и врезки в гид не идут: у них нет шоу, а значит и передачи. + ScheduleEntry.Bumper( + visible.Id, + asset.Id, + T0.AddHours(2), + T0.AddHours(2).AddSeconds(10), + null, + null + ) + ); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var channels = await GuideAsync(fixture, T0.AddHours(-1), T0.AddHours(3)); + + var dto = Assert.Single(channels); + Assert.Equal("one", dto.Slug); + var programme = Assert.Single(dto.Programmes); + Assert.Equal("Ворон", programme.Title); + } + + [Fact] + public async Task Guide_WithoutChannels_ReturnsEmpty() + { + var fixture = new TestDb(); + var channels = await GuideAsync(fixture, T0, T0.AddHours(3)); + + Assert.Empty(channels); + } + + private static async Task> GuideAsync( + TestDb fixture, + DateTimeOffset from, + DateTimeOffset to + ) + { + await using var db = fixture.New(); + return await new GetIptvGuideQueryHandler(db).Handle( + new GetIptvGuideQuery(from, to), + CancellationToken.None + ); + } + + private static Channel Enabled(string name, string slug) + { + var channel = Channel.Create(name, slug, T0); + channel.UpdateSettings(name, isEnabled: true, null); + return channel; + } + + private static ScheduleEntry Program( + Guid channelId, + Guid assetId, + Guid showId, + DateTimeOffset startsAt, + int minutes + ) => + ScheduleEntry.Program( + channelId, + assetId, + startsAt, + startsAt.AddMinutes(minutes), + showId, + 0 + ); +}