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