using Luna.Channels.Abstractions; using Luna.Channels.Telegram; using Luna.Configuration; using Luna.Shared; using Microsoft.Extensions.Logging; using Moq; using Telegram.Bot; using Telegram.Bot.Requests; using Telegram.Bot.Types; namespace Luna.Channels.Tests; [TestFixture] [Category("Unit")] public class TelegramChannelTests { private Mock _mockBotClient = null!; private Mock> _mockLogger = null!; private TelegramOptions _options = null!; private TelegramChannel _channel = null!; [SetUp] public void SetUp() { _mockBotClient = new Mock(); _mockLogger = new Mock>(); _options = new TelegramOptions { BotToken = "test-bot-token:ABCDEF" }; _channel = new TelegramChannel("100", _mockBotClient.Object, _mockLogger.Object, _options); } [TearDown] public void TearDown() { _channel.Dispose(); } [Test] public void ChannelType_Returns_Telegram() { Assert.That(_channel.ChannelType, Is.EqualTo("telegram")); } [Test] public void ChannelId_IsSet_From_Constructor() { Assert.That(_channel.ChannelId, Is.EqualTo("100")); } [Test] public void ChannelId_IsUnique_Per_Instance() { var channel1 = new TelegramChannel("111", _mockBotClient.Object, _mockLogger.Object, _options); var channel2 = new TelegramChannel("222", _mockBotClient.Object, _mockLogger.Object, _options); Assert.That(channel1.ChannelId, Is.Not.EqualTo(channel2.ChannelId)); channel1.Dispose(); channel2.Dispose(); } [Test] public void DisplayName_Contains_Telegram() { Assert.That(_channel.DisplayName, Does.Contain("Telegram")); } [Test] public void IsConnected_Is_True_Initially() { Assert.That(_channel.IsConnected, Is.True); } [Test] public void IsConnected_Is_False_After_Dispose() { _channel.Dispose(); Assert.That(_channel.IsConnected, Is.False); } [Test] public async Task SendMessageAsync_Calls_BotClient_SendMessage() { _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ReturnsAsync(new Message { Id = 1, Chat = new Chat { Id = 100 } }); var message = new ChannelMessage( Guid.NewGuid().ToString(), "100", "42", "Alice", "Hello!", DateTimeOffset.UtcNow); await _channel.SendMessageAsync(message); _mockBotClient.Verify( c => c.SendRequest(It.IsAny(), It.IsAny()), Times.Once); } [Test] public async Task SendMessageAsync_Handles_Network_Error_Without_Crash() { _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ThrowsAsync(new HttpRequestException("Network error")); var message = new ChannelMessage( Guid.NewGuid().ToString(), "100", "42", "Alice", "Hello!", DateTimeOffset.UtcNow); Assert.DoesNotThrowAsync(async () => await _channel.SendMessageAsync(message)); } [Test] public void RaiseMessageReceived_Fires_Event_With_Correct_Mapping() { ChannelMessageReceivedEventArgs? captured = null; _channel.MessageReceived += (_, args) => { captured = args; return Task.CompletedTask; }; var message = new ChannelMessage( Guid.NewGuid().ToString(), "999", "77", "Bob", "Hello Luna", DateTimeOffset.UtcNow); _channel.RaiseMessageReceived(message); Assert.That(captured, Is.Not.Null); Assert.That(captured!.Message.ConversationId, Is.EqualTo("999")); Assert.That(captured.Message.SenderId, Is.EqualTo("77")); Assert.That(captured.Message.SenderName, Is.EqualTo("Bob")); Assert.That(captured.Message.Content, Is.EqualTo("Hello Luna")); Assert.That(captured.Channel, Is.SameAs(_channel)); } [Test] public void RaiseMessageReceived_Maps_SenderName_And_Timestamp() { var sentAt = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero); ChannelMessageReceivedEventArgs? captured = null; _channel.MessageReceived += (_, args) => { captured = args; return Task.CompletedTask; }; var message = new ChannelMessage( Guid.NewGuid().ToString(), "123", "55", "Carol", "Hi", sentAt); _channel.RaiseMessageReceived(message); Assert.That(captured, Is.Not.Null); Assert.That(captured!.Message.SenderName, Is.EqualTo("Carol")); Assert.That(captured.Message.Timestamp, Is.EqualTo(sentAt)); } [Test] public async Task SendStreamingMessageAsync_Sends_Initial_Then_Edits_In_Place() { _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ReturnsAsync(new Message { Id = 10, Chat = new Chat { Id = 100 } }); _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ReturnsAsync(new Message { Id = 10, Chat = new Chat { Id = 100 } }); var updates = new List { new(ChatStreamUpdateType.Text, "Hello ", null, false), new(ChatStreamUpdateType.Text, "World", null, false), new(ChatStreamUpdateType.Complete, null, null, true), }; await _channel.SendStreamingMessageAsync(updates.ToAsyncEnumerable()); // First chunk sends a message, second chunk edits it _mockBotClient.Verify( c => c.SendRequest(It.IsAny(), It.IsAny()), Times.Once); _mockBotClient.Verify( c => c.SendRequest(It.IsAny(), It.IsAny()), Times.AtLeastOnce); } [Test] public async Task SendStreamingMessageAsync_Handles_4096_Char_Limit_By_Chunking() { var longContent = new string('A', 4097); var updates = new List { new(ChatStreamUpdateType.Text, longContent, null, false), new(ChatStreamUpdateType.Complete, null, null, true), }; _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ReturnsAsync(new Message { Id = 20, Chat = new Chat { Id = 100 } }); _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ReturnsAsync(new Message { Id = 20, Chat = new Chat { Id = 100 } }); await _channel.SendStreamingMessageAsync(updates.ToAsyncEnumerable()); // Content exceeds 4096 — needs at least 2 SendMessage calls (initial + overflow) _mockBotClient.Verify( c => c.SendRequest(It.IsAny(), It.IsAny()), Times.AtLeast(2)); } [Test] public async Task SendStreamingMessageAsync_Handles_IsComplete_True_With_Final_Edit() { _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ReturnsAsync(new Message { Id = 30, Chat = new Chat { Id = 100 } }); _mockBotClient .Setup(c => c.SendRequest(It.IsAny(), It.IsAny())) .ReturnsAsync(new Message { Id = 30, Chat = new Chat { Id = 100 } }); var updates = new List { new(ChatStreamUpdateType.Text, "Final answer", null, false), new(ChatStreamUpdateType.Complete, null, null, true), }; await _channel.SendStreamingMessageAsync(updates.ToAsyncEnumerable()); // Initial message sent _mockBotClient.Verify( c => c.SendRequest(It.IsAny(), It.IsAny()), Times.Once); } [Test] public void AllowedUserIds_Non_Empty_Blocks_Unauthorized_User() { var restrictedOptions = new TelegramOptions { BotToken = "test-token", AllowedUserIds = ["111", "222"] }; var restrictedChannel = new TelegramChannel( "500", _mockBotClient.Object, _mockLogger.Object, restrictedOptions); ChannelMessageReceivedEventArgs? captured = null; restrictedChannel.MessageReceived += (_, args) => { captured = args; return Task.CompletedTask; }; var message = new ChannelMessage( Guid.NewGuid().ToString(), "500", "999", "Hacker", "attack", DateTimeOffset.UtcNow); restrictedChannel.RaiseMessageReceived(message); Assert.That(captured, Is.Null, "Message from unauthorized user should be silently ignored"); restrictedChannel.Dispose(); } [Test] public void AllowedUserIds_Non_Empty_Allows_Authorized_User() { var restrictedOptions = new TelegramOptions { BotToken = "test-token", AllowedUserIds = ["111", "222"] }; var restrictedChannel = new TelegramChannel( "500", _mockBotClient.Object, _mockLogger.Object, restrictedOptions); ChannelMessageReceivedEventArgs? captured = null; restrictedChannel.MessageReceived += (_, args) => { captured = args; return Task.CompletedTask; }; var message = new ChannelMessage( Guid.NewGuid().ToString(), "500", "111", "TrustedUser", "hello", DateTimeOffset.UtcNow); restrictedChannel.RaiseMessageReceived(message); Assert.That(captured, Is.Not.Null, "Message from authorized user should be accepted"); restrictedChannel.Dispose(); } [Test] public void AllowedUserIds_Empty_Accepts_All_Users() { var openOptions = new TelegramOptions { BotToken = "test-token", AllowedUserIds = [] }; var openChannel = new TelegramChannel( "600", _mockBotClient.Object, _mockLogger.Object, openOptions); ChannelMessageReceivedEventArgs? captured = null; openChannel.MessageReceived += (_, args) => { captured = args; return Task.CompletedTask; }; var message = new ChannelMessage( Guid.NewGuid().ToString(), "600", "12345", "Anyone", "hello", DateTimeOffset.UtcNow); openChannel.RaiseMessageReceived(message); Assert.That(captured, Is.Not.Null, "Message should be accepted when AllowedUserIds is empty"); openChannel.Dispose(); } [Test] public void Malformed_Message_With_Null_Text_Is_Skipped() { ChannelMessageReceivedEventArgs? captured = null; _channel.MessageReceived += (_, args) => { captured = args; return Task.CompletedTask; }; var message = new ChannelMessage( Guid.NewGuid().ToString(), "700", "88", "Ghost", null!, DateTimeOffset.UtcNow); _channel.RaiseMessageReceived(message); Assert.That(captured, Is.Null, "Message with null text should be silently skipped"); } [Test] public void Malformed_Message_With_Empty_Text_Is_Skipped() { ChannelMessageReceivedEventArgs? captured = null; _channel.MessageReceived += (_, args) => { captured = args; return Task.CompletedTask; }; var message = new ChannelMessage( Guid.NewGuid().ToString(), "700", "88", "Ghost", "", DateTimeOffset.UtcNow); _channel.RaiseMessageReceived(message); Assert.That(captured, Is.Null, "Message with empty text should be silently skipped"); } [Test] public void Dispose_Fires_ConnectionStateChanged() { ChannelConnectionEventArgs? captured = null; _channel.ConnectionStateChanged += (_, args) => { captured = args; return Task.CompletedTask; }; _channel.Dispose(); Assert.That(captured, Is.Not.Null); Assert.That(captured!.IsConnected, Is.False); Assert.That(captured.Channel, Is.SameAs(_channel)); } }