Add channel system unit and integration tests

Comprehensive NUnit test suite covering ChannelManager, CliChannel,
TelegramChannel, DI registration, and integration scenarios. Uses
Moq for mocking with test stubs for isolated unit testing.
This commit is contained in:
2026-04-04 04:12:24 +02:00
parent 20ba8f99b1
commit 79492eac16
12 changed files with 1300 additions and 0 deletions
@@ -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<ILogger<ChannelManager>> loggerMock = new();
[Test]
public void RegisterChannel_And_GetChannel_Returns_It()
{
var sessionManagerMock = new Mock<ISessionManager>();
var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object);
var mockChannel = new Mock<IChannel>();
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<ISessionManager>();
var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object);
var mockChannel = new Mock<IChannel>();
mockChannel.Setup(c => c.ChannelId).Returns("dup-1");
stub.RegisterChannel(mockChannel.Object);
Assert.That(() => stub.RegisterChannel(mockChannel.Object),
Throws.TypeOf<InvalidOperationException>());
}
[Test]
public void UnregisterChannel_Removes_Channel()
{
var sessionManagerMock = new Mock<ISessionManager>();
var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object);
var mockChannel = new Mock<IChannel>();
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<ISessionManager>();
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<ISessionManager>();
var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object);
for (int i = 0; i < 3; i++)
{
var mockChannel = new Mock<IChannel>();
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<ISessionManager>();
var stub = new ChannelManager(sessionManagerMock.Object, loggerMock.Object);
var mockChannel = new Mock<IChannel>();
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);
}
}
+240
View File
@@ -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<ILogger<ChannelManager>> loggerMock = new();
private static Mock<IChannel> BuildChannelMock(string id)
{
var mock = new Mock<IChannel>();
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<IAsyncEnumerable<ChatStreamUpdate>>(),
It.IsAny<CancellationToken>()))
.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<ISessionManager>();
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<ISessionManager>();
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<InvalidOperationException>());
}
[Test]
public void UnregisterChannel_RemovesChannel_GetChannel_ReturnsNull()
{
var sessionManagerMock = new Mock<ISessionManager>();
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<ISessionManager>();
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<ISessionManager>();
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<ISessionManager>();
mockSession
.Setup(s => s.RouteMessagesAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(AsyncEnumerable.Empty<ChatStreamUpdate>());
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<string>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Test]
public async Task MessageReceived_Pipes_StreamingResponse_To_Channel()
{
async IAsyncEnumerable<ChatStreamUpdate> FakeStreamAsync()
{
yield return new ChatStreamUpdate(ChatStreamUpdateType.Text, "AI reply", null, false);
yield return new ChatStreamUpdate(ChatStreamUpdateType.Complete, null, null, true);
}
var mockSession = new Mock<ISessionManager>();
mockSession
.Setup(s => s.RouteMessagesAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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<IAsyncEnumerable<ChatStreamUpdate>>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Test]
public async Task MessageReceived_IsHandled_True_SkipsRouting()
{
var mockSession = new Mock<ISessionManager>();
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<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Test]
public async Task RouteMessagesAsync_Throws_ChannelNotDisconnected()
{
var mockSession = new Mock<ISessionManager>();
mockSession
.Setup(s => s.RouteMessagesAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.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);
}
}
+111
View File
@@ -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<IChatHubContext>();
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<IChatHubContext>();
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<IChatHubContext>();
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<IChatHubContext>();
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<IChatHubContext>();
var channel = new CliChannel("test-1", hubMock.Object);
async IAsyncEnumerable<ChatStreamUpdate> 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<IChatHubContext>();
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<IChatHubContext>();
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"));
}
}
@@ -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<IChannelManager>();
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<TelegramOptions>");
}
[Test]
public void AddChannels_CliChannel_IsRegistered()
{
_services.AddChannels();
var provider = _services.BuildServiceProvider();
var channels = provider.GetServices<IChannel>().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<IChannel>().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<IChannelManager>();
var second = provider.GetRequiredService<IChannelManager>();
Assert.That(first, Is.SameAs(second),
"IChannelManager must be registered as Singleton — same instance on every resolve");
}
}
+192
View File
@@ -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<ISessionManager> _sessionManagerMock = null!;
private Mock<IChannel> _channelMock = null!;
private ChannelManagerStub _manager = null!;
[SetUp]
public void SetUp()
{
_sessionManagerMock = new Mock<ISessionManager>();
_channelMock = new Mock<IChannel>();
_channelMock.Setup(c => c.ChannelId).Returns("test-ch-1");
_channelMock.Setup(c => c.ChannelType).Returns("test");
_channelMock.Setup(c => c.SendStreamingMessageAsync(
It.IsAny<IAsyncEnumerable<ChatStreamUpdate>>(),
It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
_manager = new ChannelManagerStub(_sessionManagerMock.Object);
}
private static async IAsyncEnumerable<ChatStreamUpdate> 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<string>(),
It.IsAny<CancellationToken>()))
.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<string>(),
It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<IAsyncEnumerable<ChatStreamUpdate>>(),
It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Test]
public async Task TwoChannels_ReceiveMessagesIndependently_BothRoutedToSessionManager()
{
var channel2Mock = new Mock<IChannel>();
channel2Mock.Setup(c => c.ChannelId).Returns("test-ch-2");
channel2Mock.Setup(c => c.ChannelType).Returns("test");
channel2Mock.Setup(c => c.SendStreamingMessageAsync(
It.IsAny<IAsyncEnumerable<ChatStreamUpdate>>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_sessionManagerMock
.Setup(s => s.RouteMessagesAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<string>(), 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<string>(),
It.IsAny<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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);
});
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NUnit" Version="4.5.1" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Telegram.Bot" Version="22.9.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Luna.Channels\Luna.Channels.csproj" />
<ProjectReference Include="..\Luna.Shared\Luna.Shared.csproj" />
<ProjectReference Include="..\Luna.Configuration\Luna.Configuration.csproj" />
<ProjectReference Include="..\Luna.Core.Abstractions\Luna.Core.Abstractions.csproj" />
<ProjectReference Include="..\Luna.Channels.Abstractions\Luna.Channels.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -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<ChannelMessageReceivedEventArgs>? 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<IChannel> GetAllChannels() => throw new NotImplementedException();
}
@@ -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<ChatStreamUpdate> messageStream, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public void RaiseMessageReceived(object? sender, ChannelMessage message) => throw new NotImplementedException();
public void Dispose() => throw new NotImplementedException();
}
@@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
namespace Luna.Channels.Tests.Stubs;
public static class ServiceCollectionExtensionsStub
{
extension(IServiceCollection services)
{
public IServiceCollection AddChannels() => services;
}
}
@@ -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<ChatStreamUpdate> 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);
}
+345
View File
@@ -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<ITelegramBotClient> _mockBotClient = null!;
private Mock<ILogger<TelegramChannel>> _mockLogger = null!;
private TelegramOptions _options = null!;
private TelegramChannel _channel = null!;
[SetUp]
public void SetUp()
{
_mockBotClient = new Mock<ITelegramBotClient>();
_mockLogger = new Mock<ILogger<TelegramChannel>>();
_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<SendMessageRequest>(), It.IsAny<CancellationToken>()))
.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<SendMessageRequest>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Test]
public async Task SendMessageAsync_Handles_Network_Error_Without_Crash()
{
_mockBotClient
.Setup(c => c.SendRequest(It.IsAny<SendMessageRequest>(), It.IsAny<CancellationToken>()))
.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<SendMessageRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Message { Id = 10, Chat = new Chat { Id = 100 } });
_mockBotClient
.Setup(c => c.SendRequest(It.IsAny<EditMessageTextRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Message { Id = 10, Chat = new Chat { Id = 100 } });
var updates = new List<ChatStreamUpdate>
{
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<SendMessageRequest>(), It.IsAny<CancellationToken>()),
Times.Once);
_mockBotClient.Verify(
c => c.SendRequest(It.IsAny<EditMessageTextRequest>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
}
[Test]
public async Task SendStreamingMessageAsync_Handles_4096_Char_Limit_By_Chunking()
{
var longContent = new string('A', 4097);
var updates = new List<ChatStreamUpdate>
{
new(ChatStreamUpdateType.Text, longContent, null, false),
new(ChatStreamUpdateType.Complete, null, null, true),
};
_mockBotClient
.Setup(c => c.SendRequest(It.IsAny<SendMessageRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Message { Id = 20, Chat = new Chat { Id = 100 } });
_mockBotClient
.Setup(c => c.SendRequest(It.IsAny<EditMessageTextRequest>(), It.IsAny<CancellationToken>()))
.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<SendMessageRequest>(), It.IsAny<CancellationToken>()),
Times.AtLeast(2));
}
[Test]
public async Task SendStreamingMessageAsync_Handles_IsComplete_True_With_Final_Edit()
{
_mockBotClient
.Setup(c => c.SendRequest(It.IsAny<SendMessageRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Message { Id = 30, Chat = new Chat { Id = 100 } });
_mockBotClient
.Setup(c => c.SendRequest(It.IsAny<EditMessageTextRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Message { Id = 30, Chat = new Chat { Id = 100 } });
var updates = new List<ChatStreamUpdate>
{
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<SendMessageRequest>(), It.IsAny<CancellationToken>()),
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));
}
}
@@ -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<string, string?>
{
["Channels:Telegram:BotToken"] = "test-token"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(config);
services.AddTelegramOptions();
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<TelegramOptions>>().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<IConfiguration>(config);
services.AddTelegramOptions();
var provider = services.BuildServiceProvider();
Assert.That(
() => provider.GetRequiredService<IOptions<TelegramOptions>>().Value,
Throws.InstanceOf<OptionsValidationException>());
}
[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);
}
}