using NSubstitute; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Notifications; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Broadcast; using TeleWave.Domain.Library; using TeleWave.Domain.Notifications; using Xunit; namespace TeleWave.Application.Tests.Notifications; /// /// Разговор с ботом. Главное свойство — подписаться можно только по одноразовому коду: рассылка /// уходит в личные чаты, и «кто нашёл бота, тот и подписался» здесь означало бы чужие оповещения. /// public class TelegramBotTests { private static readonly DateTimeOffset Now = new(2026, 8, 1, 12, 0, 0, TimeSpan.Zero); private static TelegramSettings Settings() { var settings = TelegramSettings.CreateDefault(); settings.Update( new TelegramSettingsUpdate( true, TelegramTransport.Polling, "123:ABC", null, TelegramProxyKind.None, null, null, null, null ) ); return settings; } private static TelegramUpdate Message(long chatId, string text) => new(1, chatId, text, "viewer", null, null, null); private static TelegramUpdate Callback(long chatId, string data) => new(2, chatId, null, "viewer", "cb-1", data, 55); private static ScheduleEntry Program( Guid channelId, Guid showId, DateTimeOffset start, int minutes ) => ScheduleEntry.FromSlot( channelId, Guid.NewGuid(), ScheduleEntryKind.Program, start, start.AddMinutes(minutes), new ScheduleEntryOrigin(showId, 0, null, null, null) ); [Fact] public async Task Start_WithoutCode_DoesNotSubscribeAnyone() { var fixture = new TestDb(); var api = Substitute.For(); await using var db = fixture.New(); await new TelegramBotService(db, api).HandleAsync( Settings(), Message(100, "/start"), Now, CancellationToken.None ); Assert.Empty(db.TelegramSubscribers); // Незнакомцу объясняют, где взять код, а не молчат в ответ. await api.Received(1) .SendMessageAsync( Arg.Any(), 100, Arg.Is(s => s.Contains("код")), null, Arg.Any() ); } [Fact] public async Task Start_WithCode_LinksChatToUserAndBurnsCode() { var fixture = new TestDb(); var userId = Guid.NewGuid(); var code = TelegramLinkCode.Issue("abc123", userId, Now); var channel = Channel.Create("Мультреалити", "mult", Now); channel.UpdateSettings(channel.Name, isEnabled: true, null); await using (var seed = fixture.New()) { seed.TelegramLinkCodes.Add(code); seed.Channels.Add(channel); await seed.SaveChangesAsync(CancellationToken.None); } await using var db = fixture.New(); await new TelegramBotService(db, Substitute.For()).HandleAsync( Settings(), Message(100, "/start abc123"), Now, CancellationToken.None ); var subscriber = Assert.Single(db.TelegramSubscribers); Assert.Equal(userId, subscriber.UserId); Assert.Equal(100, subscriber.ChatId); // Код одноразовый: ссылка оседает в истории браузера и в чате. Assert.NotNull(db.TelegramLinkCodes.Single().UsedAt); } [Fact] public async Task Start_WithUsedCode_IsRefused() { var fixture = new TestDb(); var code = TelegramLinkCode.Issue("abc123", Guid.NewGuid(), Now); code.MarkUsed(Now); await using (var seed = fixture.New()) { seed.TelegramLinkCodes.Add(code); await seed.SaveChangesAsync(CancellationToken.None); } await using var db = fixture.New(); await new TelegramBotService(db, Substitute.For()).HandleAsync( Settings(), Message(100, "/start abc123"), Now, CancellationToken.None ); Assert.Empty(db.TelegramSubscribers); } [Fact] public async Task Callback_TogglesSubscriptionBothWays() { var fixture = new TestDb(); var channel = Channel.Create("Мультреалити", "mult", Now); channel.UpdateSettings(channel.Name, isEnabled: true, null); var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now); await using (var seed = fixture.New()) { seed.Channels.Add(channel); seed.TelegramSubscribers.Add(subscriber); await seed.SaveChangesAsync(CancellationToken.None); } var data = $"sub:{channel.Id}:{TelegramNotificationKind.ShowStart}"; await using (var db = fixture.New()) { await new TelegramBotService(db, Substitute.For()).HandleAsync( Settings(), Callback(100, data), Now, CancellationToken.None ); } await using (var check = fixture.New()) { Assert.Single(check.TelegramSubscriptions); } await using (var db = fixture.New()) { await new TelegramBotService(db, Substitute.For()).HandleAsync( Settings(), Callback(100, data), Now, CancellationToken.None ); } await using var verify = fixture.New(); // Повторное нажатие выключает: кнопка — тумблер, а не «добавить ещё одну подписку». Assert.Empty(verify.TelegramSubscriptions); } [Fact] public async Task Callback_Schedule_SendsUpcomingProgrammes() { // Кнопка-заголовок канала показывает программу. Подряд идущие серии одного шоу — это // один блок: зритель спрашивает «что дальше», а не «в каком порядке лежат файлы». var fixture = new TestDb(); var channel = Channel.Create("Мультреалити", "mult", Now); channel.UpdateSettings(channel.Name, isEnabled: true, null); var first = Show.Create("Симпсоны", ShowKind.Series); var second = Show.Create("Футурама", ShowKind.Series); var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now); await using (var seed = fixture.New()) { seed.Channels.Add(channel); seed.Shows.AddRange(first, second); seed.TelegramSubscribers.Add(subscriber); seed.ScheduleEntries.AddRange( Program(channel.Id, first.Id, Now.AddMinutes(-5), 25), Program(channel.Id, first.Id, Now.AddMinutes(20), 25), Program(channel.Id, second.Id, Now.AddMinutes(45), 25) ); await seed.SaveChangesAsync(CancellationToken.None); } var api = Substitute.For(); string? sent = null; api.SendMessageAsync( Arg.Any(), Arg.Any(), Arg.Do(text => sent = text), Arg.Any>?>(), Arg.Any() ) .Returns(4242L); await using var db = fixture.New(); await new TelegramBotService(db, api).HandleAsync( Settings(), Callback(100, $"epg:{channel.Id}"), Now, CancellationToken.None ); Assert.NotNull(sent); Assert.Contains("Симпсоны", sent, StringComparison.Ordinal); // Программа — одноразовое сообщение: её идентификатор запомнен, чтобы снять при следующем // показе. Без этого чат копит одинаковые программы на разное время. await using (var check = fixture.New()) { Assert.Equal(4242L, check.TelegramSubscribers.Single().NoticeMessageId); } Assert.Contains("Футурама", sent, StringComparison.Ordinal); // Две подряд идущие серии Симпсонов дают одну строку, а не две. Assert.Equal(1, sent!.Split("Симпсоны").Length - 1); } [Fact] public async Task Menu_ReplacesThePreviousOne() { // Два меню в чате — это две панели с разными галочками, и одна из них врёт. Старое снимаем. var fixture = new TestDb(); var channel = Channel.Create("Мультреалити", "mult", Now); channel.UpdateSettings(channel.Name, isEnabled: true, null); var subscriber = TelegramSubscriber.Create(100, Guid.NewGuid(), "viewer", Now); await using (var seed = fixture.New()) { seed.Channels.Add(channel); seed.TelegramSubscribers.Add(subscriber); await seed.SaveChangesAsync(CancellationToken.None); } var api = Substitute.For(); api.SendMessageAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>?>(), Arg.Any() ) .Returns(777L); await using (var db = fixture.New()) { await new TelegramBotService(db, api).HandleAsync( Settings(), Message(100, "/start"), Now, CancellationToken.None ); } await using (var check = fixture.New()) { Assert.Equal(777L, check.TelegramSubscribers.Single().MenuMessageId); } await using (var db = fixture.New()) { await new TelegramBotService(db, api).HandleAsync( Settings(), Message(100, "/start"), Now, CancellationToken.None ); } // Второй заход снял предыдущее меню, а не оставил его висеть. await api.Received(1) .DeleteMessageAsync( Arg.Any(), 100, 777L, Arg.Any() ); } [Fact] public async Task Callback_FromUnknownChat_IsRefused() { var fixture = new TestDb(); var api = Substitute.For(); await using var db = fixture.New(); await new TelegramBotService(db, api).HandleAsync( Settings(), Callback(100, $"sub:{Guid.NewGuid()}:{TelegramNotificationKind.ShowStart}"), Now, CancellationToken.None ); Assert.Empty(db.TelegramSubscriptions); } }