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,
// Предикат NSubstitute принимает аргумент как допускающий null — само сообщение
// объявлено обязательным, потому и разыменовываем без проверки.
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, 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, 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, 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, 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);
}
}