diff --git a/Luna.Channels.Tests/ChannelManagerInterfaceTests.cs b/Luna.Channels.Tests/ChannelManagerInterfaceTests.cs new file mode 100644 index 0000000..2a1adc6 --- /dev/null +++ b/Luna.Channels.Tests/ChannelManagerInterfaceTests.cs @@ -0,0 +1,114 @@ +using Luna.Channels.Abstractions; +using Luna.Channels.Tests.Stubs; +using Luna.Core.Abstractions; +using Luna.Shared; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Luna.Channels.Tests; + +[TestFixture] +[Category("Unit")] +public class ChannelManagerInterfaceTests +{ + private readonly Mock> loggerMock = new(); + + [Test] + public void RegisterChannel_And_GetChannel_Returns_It() + { + var sessionManagerMock = new Mock(); + var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + var mockChannel = new Mock(); + mockChannel.Setup(c => c.ChannelId).Returns("test-1"); + + stub.RegisterChannel(mockChannel.Object); + var result = stub.GetChannel("test-1"); + + Assert.That(result, Is.EqualTo(mockChannel.Object)); + } + + [Test] + public void RegisterChannel_Duplicate_Id_Throws() + { + var sessionManagerMock = new Mock(); + var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + var mockChannel = new Mock(); + mockChannel.Setup(c => c.ChannelId).Returns("dup-1"); + + stub.RegisterChannel(mockChannel.Object); + + Assert.That(() => stub.RegisterChannel(mockChannel.Object), + Throws.TypeOf()); + } + + [Test] + public void UnregisterChannel_Removes_Channel() + { + var sessionManagerMock = new Mock(); + var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + var mockChannel = new Mock(); + mockChannel.Setup(c => c.ChannelId).Returns("rem-1"); + + stub.RegisterChannel(mockChannel.Object); + stub.UnregisterChannel("rem-1"); + var result = stub.GetChannel("rem-1"); + + Assert.That(result, Is.Null); + } + + [Test] + public void UnregisterChannel_Unknown_Id_No_Exception() + { + var sessionManagerMock = new Mock(); + var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + + Assert.That(() => stub.UnregisterChannel("nonexistent-id"), Throws.Nothing); + } + + [Test] + public void GetAllChannels_Returns_All_Registered() + { + var sessionManagerMock = new Mock(); + var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + for (int i = 0; i < 3; i++) + { + var mockChannel = new Mock(); + mockChannel.Setup(c => c.ChannelId).Returns($"ch-{i}"); + stub.RegisterChannel(mockChannel.Object); + } + + var all = stub.GetAllChannels(); + + Assert.That(all.Count, Is.EqualTo(3)); + } + + [Test] + public void MessageRouted_Event_Fires_When_Message_Is_Routed() + { + var sessionManagerMock = new Mock(); + var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + var mockChannel = new Mock(); + mockChannel.Setup(c => c.ChannelId).Returns("event-ch-1"); + + ChannelMessageReceivedEventArgs? capturedArgs = null; + stub.MessageRouted += (_, args) => capturedArgs = args; + + stub.RegisterChannel(mockChannel.Object); + + var message = new ChannelMessage( + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString(), + "sender-1", + "Sender Name", + "Hello", + DateTimeOffset.UtcNow); + + var eventArgs = new ChannelMessageReceivedEventArgs + { + Message = message, + Channel = mockChannel.Object + }; + + Assert.That(capturedArgs, Is.Not.Null); + } +} diff --git a/Luna.Channels.Tests/ChannelManagerTests.cs b/Luna.Channels.Tests/ChannelManagerTests.cs new file mode 100644 index 0000000..d1f24ed --- /dev/null +++ b/Luna.Channels.Tests/ChannelManagerTests.cs @@ -0,0 +1,240 @@ +using Luna.Channels.Abstractions; +using Luna.Channels.Tests.Stubs; +using Luna.Core.Abstractions; +using Luna.Shared; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Luna.Channels.Tests; + +[TestFixture] +[Category("Unit")] +public class ChannelManagerTests +{ + private readonly Mock> loggerMock = new(); + + private static Mock BuildChannelMock(string id) + { + var mock = new Mock(); + mock.Setup(c => c.ChannelId).Returns(id); + mock.Setup(c => c.ChannelType).Returns("test"); + mock.Setup(c => c.DisplayName).Returns($"Channel {id}"); + mock.Setup(c => c.IsConnected).Returns(true); + mock.Setup(c => c.SendStreamingMessageAsync( + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + return mock; + } + + private static ChannelMessage BuildMessage(string content = "Hello") => + new ChannelMessage( + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString(), + "sender-1", + "Sender Name", + content, + DateTimeOffset.UtcNow); + + [Test] + public void RegisterChannel_AddsToRegistry_GetChannel_ReturnsIt() + { + var sessionManagerMock = new Mock(); + var sut = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + var mockChannel = BuildChannelMock("ch-reg-1"); + + sut.RegisterChannel(mockChannel.Object); + var result = sut.GetChannel("ch-reg-1"); + + Assert.That(result, Is.SameAs(mockChannel.Object)); + } + + [Test] + public void RegisterChannel_DuplicateId_Throws_InvalidOperationException() + { + var sessionManagerMock = new Mock(); + var sut = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + var mockChannel = BuildChannelMock("ch-dup-1"); + + sut.RegisterChannel(mockChannel.Object); + + Assert.That( + () => sut.RegisterChannel(mockChannel.Object), + Throws.TypeOf()); + } + + [Test] + public void UnregisterChannel_RemovesChannel_GetChannel_ReturnsNull() + { + var sessionManagerMock = new Mock(); + var sut = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + var mockChannel = BuildChannelMock("ch-rem-1"); + + sut.RegisterChannel(mockChannel.Object); + sut.UnregisterChannel("ch-rem-1"); + var result = sut.GetChannel("ch-rem-1"); + + Assert.That(result, Is.Null); + } + + [Test] + public void UnregisterChannel_UnknownId_NoOp() + { + var sessionManagerMock = new Mock(); + var sut = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + + Assert.That(() => sut.UnregisterChannel("does-not-exist"), Throws.Nothing); + } + + [Test] + public void GetAllChannels_ReturnsAllRegistered() + { + var sessionManagerMock = new Mock(); + var sut = new ChannelManager(sessionManagerMock.Object, loggerMock.Object); + + for (var i = 0; i < 3; i++) + sut.RegisterChannel(new CliChannelStub { ChannelId = i.ToString() }); + + var all = sut.GetAllChannels(); + + Assert.That(all.Count, Is.EqualTo(3)); + } + + [Test] + public async Task MessageReceived_Routes_To_SessionManager() + { + var mockSession = new Mock(); + mockSession + .Setup(s => s.RouteMessagesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + + var sut = new ChannelManager(mockSession.Object, loggerMock.Object); + var mockChannel = BuildChannelMock("ch-route-1"); + + sut.RegisterChannel(mockChannel.Object); + + var message = BuildMessage("Hello from channel"); + mockChannel.Raise(c => c.MessageReceived += null, mockChannel.Object, + new ChannelMessageReceivedEventArgs + { + Message = message, + Channel = mockChannel.Object + }); + + await Task.Delay(100); + + mockSession.Verify( + s => s.RouteMessagesAsync( + message.Content, + message.ConversationId, + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task MessageReceived_Pipes_StreamingResponse_To_Channel() + { + async IAsyncEnumerable FakeStreamAsync() + { + yield return new ChatStreamUpdate(ChatStreamUpdateType.Text, "AI reply", null, false); + yield return new ChatStreamUpdate(ChatStreamUpdateType.Complete, null, null, true); + } + + var mockSession = new Mock(); + mockSession + .Setup(s => s.RouteMessagesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(FakeStreamAsync()); + + var sut = new ChannelManager(mockSession.Object, loggerMock.Object); + var mockChannel = BuildChannelMock("ch-stream-1"); + + sut.RegisterChannel(mockChannel.Object); + + await mockChannel.RaiseAsync(c => c.MessageReceived += null, mockChannel.Object, + new ChannelMessageReceivedEventArgs + { + Message = BuildMessage("stream test"), + Channel = mockChannel.Object + }); + + await Task.Delay(200); + + mockChannel.Verify( + c => c.SendStreamingMessageAsync( + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Test] + public async Task MessageReceived_IsHandled_True_SkipsRouting() + { + var mockSession = new Mock(); + + var sut = new ChannelManager(mockSession.Object, loggerMock.Object); + var mockChannel = BuildChannelMock("ch-handled-1"); + + sut.RegisterChannel(mockChannel.Object); + + await mockChannel.RaiseAsync(c => c.MessageReceived += null, mockChannel.Object, + new ChannelMessageReceivedEventArgs + { + Message = BuildMessage("already handled"), + Channel = mockChannel.Object, + IsHandled = true + }); + + await Task.Delay(100); + + mockSession.Verify( + s => s.RouteMessagesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Test] + public async Task RouteMessagesAsync_Throws_ChannelNotDisconnected() + { + var mockSession = new Mock(); + mockSession + .Setup(s => s.RouteMessagesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Throws(new Exception("Simulated routing failure")); + + var sut = new ChannelManager(mockSession.Object, loggerMock.Object); + var mockChannel = BuildChannelMock("ch-err-1"); + mockChannel.Setup(c => c.IsConnected).Returns(true); + + sut.RegisterChannel(mockChannel.Object); + + Assert.That( + async () => + { + await mockChannel.RaiseAsync(c => c.MessageReceived += null, mockChannel.Object, + new ChannelMessageReceivedEventArgs + { + Message = BuildMessage("error trigger"), + Channel = mockChannel.Object + }); + await Task.Delay(200); + }, + Throws.Nothing); + + Assert.That(mockChannel.Object.IsConnected, Is.True); + } +} diff --git a/Luna.Channels.Tests/CliChannelTests.cs b/Luna.Channels.Tests/CliChannelTests.cs new file mode 100644 index 0000000..6b741ff --- /dev/null +++ b/Luna.Channels.Tests/CliChannelTests.cs @@ -0,0 +1,111 @@ +using Luna.Channels.Abstractions; +using Luna.Channels.Cli; +using Luna.Channels.Tests.Stubs; +using Luna.Core.Abstractions; +using Luna.Shared; +using Moq; + +namespace Luna.Channels.Tests; + +[TestFixture] +[Category("Unit")] +public class CliChannelTests +{ + [Test] + public void ChannelType_Returns_Cli() + { + var hubMock = new Mock(); + var channel = new CliChannel("test-1", hubMock.Object); + + Assert.That(channel.ChannelType, Is.EqualTo("cli")); + } + + [Test] + public void ChannelId_IsUnique_PerInstance() + { + var hubMock = new Mock(); + var channel1 = new CliChannel("test-1", hubMock.Object); + var channel2 = new CliChannel("test-2", hubMock.Object); + + Assert.That(channel1.ChannelId, Is.Not.EqualTo(channel2.ChannelId)); + } + + [Test] + public void DisplayName_Returns_HumanReadable() + { + var hubMock = new Mock(); + var channel = new CliChannel("test-1", hubMock.Object); + + Assert.That(channel.DisplayName, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task SendMessageAsync_Sends_ChannelMessage() + { + var hubMock = new Mock(); + var channel = new CliChannel("test-1", hubMock.Object); + var message = new ChannelMessage( + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString(), + "cli-user-1", + "CLI User", + "Hello from CLI", + DateTimeOffset.UtcNow); + + await channel.SendMessageAsync(message); + } + + [Test] + public async Task SendStreamingMessageAsync_Consumes_Stream() + { + var hubMock = new Mock(); + var channel = new CliChannel("test-1", hubMock.Object); + + async IAsyncEnumerable TestStream() + { + yield return new ChatStreamUpdate(ChatStreamUpdateType.Text, "Hello", "assistant", false); + yield return new ChatStreamUpdate(ChatStreamUpdateType.Text, " world", "assistant", false); + yield return new ChatStreamUpdate(ChatStreamUpdateType.Complete, null, "assistant", true); + await Task.CompletedTask; + } + + await channel.SendStreamingMessageAsync(TestStream()); + } + + [Test] + public void Dispose_CleansUp() + { + var hubMock = new Mock(); + var channel = new CliChannel("test-1", hubMock.Object); + + Assert.That(() => channel.Dispose(), Throws.Nothing); + } + + [Test] + public void MessageReceived_Event_Fires_On_RaiseMessageReceived() + { + var hubMock = new Mock(); + var channel = new CliChannel("test-1", hubMock.Object); + ChannelMessageReceivedEventArgs? capturedArgs = null; + + channel.MessageReceived += (_, args) => + { + capturedArgs = args; + return Task.CompletedTask; + }; + + var message = new ChannelMessage( + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString(), + "cli-user-1", + "CLI User", + "Hello from CLI", + DateTimeOffset.UtcNow); + + channel.RaiseMessageReceived(null, message); + + Assert.That(capturedArgs, Is.Not.Null, + "MessageReceived event must fire when RaiseMessageReceived is called."); + Assert.That(capturedArgs!.Message.Content, Is.EqualTo("Hello from CLI")); + } +} diff --git a/Luna.Channels.Tests/DependencyInjectionTests.cs b/Luna.Channels.Tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..cc98f76 --- /dev/null +++ b/Luna.Channels.Tests/DependencyInjectionTests.cs @@ -0,0 +1,110 @@ +using Luna.Channels.Abstractions; +using Luna.Channels.Tests.Stubs; +using Luna.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Luna.Channels.Tests; + +[TestFixture] +[Category("Unit")] +public class DependencyInjectionTests +{ + private ServiceCollection _services = null!; + + [SetUp] + public void SetUp() + { + _services = new ServiceCollection(); + } + + [Test] + public void AddChannels_RegistersIChannelManager_AsSingleton() + { + _services.AddChannels(); + var provider = _services.BuildServiceProvider(); + + var descriptor = _services.FirstOrDefault(d => d.ServiceType == typeof(IChannelManager)); + + Assert.That(descriptor, Is.Not.Null); + Assert.That(descriptor!.Lifetime, Is.EqualTo(ServiceLifetime.Singleton)); + } + + [Test] + public void AddChannels_IChannelManager_IsResolvableFromContainer() + { + _services.AddChannels(); + var provider = _services.BuildServiceProvider(); + + var manager = provider.GetService(); + + Assert.That(manager, Is.Not.Null); + } + + [Test] + public void AddChannels_TelegramOptions_IsRegistered() + { + _services.AddChannels(); + var provider = _services.BuildServiceProvider(); + + var descriptor = _services.FirstOrDefault(d => + d.ServiceType.IsGenericType && + d.ServiceType.GetGenericTypeDefinition() == typeof(IOptions<>) && + d.ServiceType.GetGenericArguments()[0] == typeof(TelegramOptions)); + + Assert.That(descriptor, Is.Not.Null, + "AddChannels() must register TelegramOptions via IOptions"); + } + + [Test] + public void AddChannels_CliChannel_IsRegistered() + { + _services.AddChannels(); + var provider = _services.BuildServiceProvider(); + + var channels = provider.GetServices().ToList(); + var channelTypes = channels.Select(c => c.ChannelType).ToList(); + + Assert.That(channelTypes, Contains.Item("cli"), + "AddChannels() must register a CliChannel with ChannelType 'cli'"); + } + + [Test] + public void AddChannels_TelegramChannel_IsRegistered() + { + _services.AddChannels(); + var provider = _services.BuildServiceProvider(); + + var channels = provider.GetServices().ToList(); + var channelTypes = channels.Select(c => c.ChannelType).ToList(); + + Assert.That(channelTypes, Contains.Item("telegram"), + "AddChannels() must register a TelegramChannel with ChannelType 'telegram'"); + } + + [Test] + public void AddChannels_CalledTwice_DoesNotDuplicateSingletonRegistration() + { + _services.AddChannels(); + _services.AddChannels(); + var provider = _services.BuildServiceProvider(); + + var descriptors = _services.Where(d => d.ServiceType == typeof(IChannelManager)).ToList(); + + Assert.That(descriptors, Has.Count.EqualTo(1), + "Calling AddChannels() twice must not register IChannelManager twice"); + } + + [Test] + public void AddChannels_SameProviderInstance_ReturnsSameIChannelManager() + { + _services.AddChannels(); + var provider = _services.BuildServiceProvider(); + + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); + + Assert.That(first, Is.SameAs(second), + "IChannelManager must be registered as Singleton — same instance on every resolve"); + } +} diff --git a/Luna.Channels.Tests/IntegrationTests.cs b/Luna.Channels.Tests/IntegrationTests.cs new file mode 100644 index 0000000..2dd7173 --- /dev/null +++ b/Luna.Channels.Tests/IntegrationTests.cs @@ -0,0 +1,192 @@ +using Luna.Channels.Abstractions; +using Luna.Channels.Tests.Stubs; +using Luna.Core.Abstractions; +using Luna.Shared; +using Moq; + +namespace Luna.Channels.Tests; + +[TestFixture] +[Category("Unit")] +public class IntegrationTests +{ + private Mock _sessionManagerMock = null!; + private Mock _channelMock = null!; + private ChannelManagerStub _manager = null!; + + [SetUp] + public void SetUp() + { + _sessionManagerMock = new Mock(); + _channelMock = new Mock(); + _channelMock.Setup(c => c.ChannelId).Returns("test-ch-1"); + _channelMock.Setup(c => c.ChannelType).Returns("test"); + _channelMock.Setup(c => c.SendStreamingMessageAsync( + It.IsAny>(), + It.IsAny())).Returns(Task.CompletedTask); + _manager = new ChannelManagerStub(_sessionManagerMock.Object); + } + + private static async IAsyncEnumerable BuildStream(params ChatStreamUpdate[] updates) + { + foreach (var u in updates) + { + yield return u; + await Task.Yield(); + } + } + + [Test] + public async Task MessageReceived_RoutesToSessionManager_WithCorrectContent() + { + var message = new ChannelMessage( + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "user-1", "User One", "Hello Luna!", DateTimeOffset.UtcNow); + + _sessionManagerMock + .Setup(s => s.RouteMessagesAsync( + message.Content, + message.ConversationId.ToString(), + It.IsAny(), + It.IsAny())) + .Returns(BuildStream(new ChatStreamUpdate(ChatStreamUpdateType.Text, "Hi!", null, false))); + + _manager.RegisterChannel(new CliChannelStub { ChannelId = "test-1" }); + + var args = new ChannelMessageReceivedEventArgs { Message = message, Channel = _channelMock.Object }; + _channelMock.Raise(c => c.MessageReceived += null, _channelMock.Object, args); + + await Task.Delay(50); + + _sessionManagerMock.Verify(s => s.RouteMessagesAsync( + message.Content, + message.ConversationId.ToString(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Test] + public async Task MessageReceived_PipesSessionResponseToChannel_SendStreamingMessageAsync() + { + var message = new ChannelMessage( + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "user-2", "User Two", "What time is it?", DateTimeOffset.UtcNow); + + var responseStream = BuildStream( + new ChatStreamUpdate(ChatStreamUpdateType.Text, "It's ", null, false), + new ChatStreamUpdate(ChatStreamUpdateType.Text, "noon.", null, false), + new ChatStreamUpdate(ChatStreamUpdateType.Complete, null, null, true)); + + _sessionManagerMock + .Setup(s => s.RouteMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(responseStream); + + _manager.RegisterChannel(new CliChannelStub { ChannelId = "test-1" }); + + var args = new ChannelMessageReceivedEventArgs { Message = message, Channel = _channelMock.Object }; + _channelMock.Raise(c => c.MessageReceived += null, _channelMock.Object, args); + + await Task.Delay(50); + + _channelMock.Verify(c => c.SendStreamingMessageAsync( + It.IsAny>(), + It.IsAny()), Times.Once); + } + + [Test] + public async Task MessageReceived_WhenIsHandled_True_DoesNotRouteToSessionManager() + { + var message = new ChannelMessage( + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "user-3", "User Three", "/start", DateTimeOffset.UtcNow); + + _manager.RegisterChannel(new CliChannelStub { ChannelId = "test-1" }); + + var args = new ChannelMessageReceivedEventArgs { Message = message, Channel = _channelMock.Object, IsHandled = true }; + _channelMock.Raise(c => c.MessageReceived += null, _channelMock.Object, args); + + await Task.Delay(50); + + _sessionManagerMock.Verify(s => s.RouteMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task TwoChannels_ReceiveMessagesIndependently_BothRoutedToSessionManager() + { + var channel2Mock = new Mock(); + channel2Mock.Setup(c => c.ChannelId).Returns("test-ch-2"); + channel2Mock.Setup(c => c.ChannelType).Returns("test"); + channel2Mock.Setup(c => c.SendStreamingMessageAsync( + It.IsAny>(), It.IsAny())) + .Returns(Task.CompletedTask); + + _sessionManagerMock + .Setup(s => s.RouteMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(BuildStream(new ChatStreamUpdate(ChatStreamUpdateType.Text, "ok", null, true))); + + _manager.RegisterChannel(new CliChannelStub { ChannelId = "test-1" }); + _manager.RegisterChannel(channel2Mock.Object); + + var msg1 = new ChannelMessage(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "u1", "U1", "Msg1", DateTimeOffset.UtcNow); + var msg2 = new ChannelMessage(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "u2", "U2", "Msg2", DateTimeOffset.UtcNow); + + _channelMock.Raise(c => c.MessageReceived += null, _channelMock.Object, + new ChannelMessageReceivedEventArgs { Message = msg1, Channel = _channelMock.Object }); + channel2Mock.Raise(c => c.MessageReceived += null, channel2Mock.Object, + new ChannelMessageReceivedEventArgs { Message = msg2, Channel = channel2Mock.Object }); + + await Task.Delay(100); + + _sessionManagerMock.Verify(s => s.RouteMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Test] + public async Task SessionManager_RouteMessagesAsync_ReceivesCancellationToken() + { + using var cts = new CancellationTokenSource(); + var message = new ChannelMessage( + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "user-4", "User Four", "Cancel me", DateTimeOffset.UtcNow); + + _sessionManagerMock + .Setup(s => s.RouteMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), cts.Token)) + .Returns(BuildStream(new ChatStreamUpdate(ChatStreamUpdateType.Text, "x", null, true))); + + _manager.RegisterChannel(new CliChannelStub { ChannelId = "test-1" }); + + var args = new ChannelMessageReceivedEventArgs { Message = message, Channel = _channelMock.Object }; + await _channelMock.RaiseAsync(c => c.MessageReceived += null, _channelMock.Object, args); + + await Task.Delay(50, cts.Token); + + _sessionManagerMock.Verify(s => s.RouteMessagesAsync( + message.Content, + message.ConversationId.ToString(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Test] + public async Task SessionManager_Throws_ChannelRemainsActive_NotDisconnected() + { + var message = new ChannelMessage( + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), "user-5", "User Five", "Boom", DateTimeOffset.UtcNow); + + _sessionManagerMock + .Setup(s => s.RouteMessagesAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new InvalidOperationException("Simulated routing failure")); + + _manager.RegisterChannel(new CliChannelStub { ChannelId = "test-1" }); + + Assert.DoesNotThrowAsync(async () => + { + var args = new ChannelMessageReceivedEventArgs { Message = message, Channel = _channelMock.Object }; + await _channelMock.RaiseAsync(c => c.MessageReceived += null, _channelMock.Object, args); + await Task.Delay(50); + }); + } +} diff --git a/Luna.Channels.Tests/Luna.Channels.Tests.csproj b/Luna.Channels.Tests/Luna.Channels.Tests.csproj new file mode 100644 index 0000000..117212d --- /dev/null +++ b/Luna.Channels.Tests/Luna.Channels.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Luna.Channels.Tests/Stubs/ChannelManagerStub.cs b/Luna.Channels.Tests/Stubs/ChannelManagerStub.cs new file mode 100644 index 0000000..cf495e3 --- /dev/null +++ b/Luna.Channels.Tests/Stubs/ChannelManagerStub.cs @@ -0,0 +1,20 @@ +using Luna.Channels.Abstractions; +using Luna.Core.Abstractions; + +namespace Luna.Channels.Tests.Stubs; + +public class ChannelManagerStub : IChannelManager +{ + public ChannelManagerStub() { } + public ChannelManagerStub(ISessionManager sessionManager) { } + + public event EventHandler? MessageRouted; + + public void RegisterChannel(IChannel channel) => throw new NotImplementedException(); + + public void UnregisterChannel(string channelId) => throw new NotImplementedException(); + + public IChannel? GetChannel(string channelId) => throw new NotImplementedException(); + + public IReadOnlyList GetAllChannels() => throw new NotImplementedException(); +} diff --git a/Luna.Channels.Tests/Stubs/CliChannelStub.cs b/Luna.Channels.Tests/Stubs/CliChannelStub.cs new file mode 100644 index 0000000..f04e261 --- /dev/null +++ b/Luna.Channels.Tests/Stubs/CliChannelStub.cs @@ -0,0 +1,19 @@ +using Luna.Channels.Abstractions; +using Luna.Shared; + +namespace Luna.Channels.Tests.Stubs; + +public class CliChannelStub : IChannel +{ + public required string ChannelId { get; init; } + public string ChannelType => throw new NotImplementedException(); + public string DisplayName => throw new NotImplementedException(); + public bool IsConnected => throw new NotImplementedException(); + public event ChannelMessageReceivedEventHandler? MessageReceived; + public event ChannelConnectionChangedEventHandler? ConnectionStateChanged; + public Task SendMessageAsync(ChannelMessage message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task SendStreamingMessageAsync(IAsyncEnumerable messageStream, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public void RaiseMessageReceived(object? sender, ChannelMessage message) => throw new NotImplementedException(); + + public void Dispose() => throw new NotImplementedException(); +} diff --git a/Luna.Channels.Tests/Stubs/ServiceCollectionExtensionsStub.cs b/Luna.Channels.Tests/Stubs/ServiceCollectionExtensionsStub.cs new file mode 100644 index 0000000..d2622e3 --- /dev/null +++ b/Luna.Channels.Tests/Stubs/ServiceCollectionExtensionsStub.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Luna.Channels.Tests.Stubs; + +public static class ServiceCollectionExtensionsStub +{ + extension(IServiceCollection services) + { + public IServiceCollection AddChannels() => services; + } +} diff --git a/Luna.Channels.Tests/Stubs/TelegramChannelStub.cs b/Luna.Channels.Tests/Stubs/TelegramChannelStub.cs new file mode 100644 index 0000000..9d94994 --- /dev/null +++ b/Luna.Channels.Tests/Stubs/TelegramChannelStub.cs @@ -0,0 +1,40 @@ +using Luna.Channels.Abstractions; +using Luna.Shared; + +namespace Luna.Channels.Tests.Stubs; + +public class TelegramChannelStub : IChannel +{ + public string ChannelId => throw new NotImplementedException(); + public string ChannelType => throw new NotImplementedException(); + public string DisplayName => throw new NotImplementedException(); + public bool IsConnected => throw new NotImplementedException(); + + public event ChannelMessageReceivedEventHandler? MessageReceived; + public event ChannelConnectionChangedEventHandler? ConnectionStateChanged; + + public Task SendMessageAsync(ChannelMessage message, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public Task SendStreamingMessageAsync( + IAsyncEnumerable messageStream, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + public void RaiseMessageReceived(object? sender, ChannelMessage message) + { + RaiseMessageReceived(new ChannelMessageReceivedEventArgs + { + Channel = this, + Message = message + }); + } + + public void Dispose() => throw new NotImplementedException(); + + internal void RaiseMessageReceived(ChannelMessageReceivedEventArgs args) => + MessageReceived?.Invoke(this, args); + + internal void RaiseConnectionStateChanged(ChannelConnectionEventArgs args) => + ConnectionStateChanged?.Invoke(this, args); +} diff --git a/Luna.Channels.Tests/TelegramChannelTests.cs b/Luna.Channels.Tests/TelegramChannelTests.cs new file mode 100644 index 0000000..cf2ebc9 --- /dev/null +++ b/Luna.Channels.Tests/TelegramChannelTests.cs @@ -0,0 +1,345 @@ +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)); + } +} diff --git a/Luna.Channels.Tests/TelegramOptionsTests.cs b/Luna.Channels.Tests/TelegramOptionsTests.cs new file mode 100644 index 0000000..54a385c --- /dev/null +++ b/Luna.Channels.Tests/TelegramOptionsTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Luna.Configuration; +using Luna.Configuration.Extensions; + +namespace Luna.Channels.Tests; + +[TestFixture] +[Category("Unit")] +public class TelegramOptionsTests +{ + [Test] + public void BotToken_Binds_From_Configuration() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Channels:Telegram:BotToken"] = "test-token" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddTelegramOptions(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.That(options.BotToken, Is.EqualTo("test-token")); + } + + [Test] + public void Validation_Fails_When_BotToken_Missing() + { + var config = new ConfigurationBuilder().Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddTelegramOptions(); + + var provider = services.BuildServiceProvider(); + + Assert.That( + () => provider.GetRequiredService>().Value, + Throws.InstanceOf()); + } + + [Test] + public void PollingTimeoutSeconds_Defaults_To_30() + { + var options = new TelegramOptions { BotToken = "x" }; + + Assert.That(options.PollingTimeoutSeconds, Is.EqualTo(30)); + } + + [Test] + public void AllowedUserIds_Defaults_To_Empty() + { + var options = new TelegramOptions { BotToken = "x" }; + + Assert.That(options.AllowedUserIds, Is.Empty); + } +}