diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..c1b01ed --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,87 @@ +name: Tests +on: + push: + branches: + - master + - develop + pull_request: + types: [opened, synchronize, reopened] + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore dependencies + run: dotnet restore --verbosity normal + + - name: Build solution + run: dotnet build --no-restore --verbosity normal + + - name: Run unit tests + run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./TestResults + + - name: Generate test report + if: always() + run: | + echo "Test results:" + find ./TestResults -name "*.trx" -exec echo "Found test result file: {}" \; + find ./TestResults -name "*.xml" -exec echo "Found coverage file: {}" \; + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ./TestResults/ + retention-days: 30 + + test-windows: + name: Run Tests (Windows) + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore dependencies + run: dotnet restore --verbosity normal + + - name: Build solution + run: dotnet build --no-restore --verbosity normal + + - name: Run unit tests + run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./TestResults + + - name: Generate test report + if: always() + run: | + echo "Test results:" + Get-ChildItem -Path ./TestResults -Recurse -Filter "*.trx" | ForEach-Object { Write-Host "Found test result file: $($_.FullName)" } + Get-ChildItem -Path ./TestResults -Recurse -Filter "*.xml" | ForEach-Object { Write-Host "Found coverage file: $($_.FullName)" } + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-windows + path: ./TestResults/ + retention-days: 30 diff --git a/ChatBot.Tests/ChatBot.Tests.csproj b/ChatBot.Tests/ChatBot.Tests.csproj new file mode 100644 index 0000000..34f67e5 --- /dev/null +++ b/ChatBot.Tests/ChatBot.Tests.csproj @@ -0,0 +1,39 @@ + + + net9.0 + enable + enable + false + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + diff --git a/ChatBot.Tests/Configuration/Validators/AISettingsValidatorTests.cs b/ChatBot.Tests/Configuration/Validators/AISettingsValidatorTests.cs new file mode 100644 index 0000000..5f29bcd --- /dev/null +++ b/ChatBot.Tests/Configuration/Validators/AISettingsValidatorTests.cs @@ -0,0 +1,163 @@ +using ChatBot.Models.Configuration; +using ChatBot.Models.Configuration.Validators; +using FluentAssertions; + +namespace ChatBot.Tests.Configuration.Validators; + +public class AISettingsValidatorTests +{ + private readonly AISettingsValidator _validator = new(); + + [Fact] + public void Validate_ShouldReturnSuccess_WhenSettingsAreValid() + { + // Arrange + var settings = new AISettings + { + Temperature = 0.7, + SystemPromptPath = "Prompts/system-prompt.txt", + MaxRetryAttempts = 3, + RetryDelayMs = 1000, + MaxRetryDelayMs = 10000, + EnableExponentialBackoff = true, + RequestTimeoutSeconds = 30, + EnableHistoryCompression = true, + CompressionThreshold = 10, + CompressionTarget = 5, + MinMessageLengthForSummarization = 50, + MaxSummarizedMessageLength = 200, + CompressionTimeoutSeconds = 15, + StatusCheckTimeoutSeconds = 5, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenTemperatureIsInvalid() + { + // Arrange + var settings = new AISettings + { + Temperature = 3.0, // Invalid: > 2.0 + SystemPromptPath = "Prompts/system-prompt.txt", + MaxRetryAttempts = 3, + RetryDelayMs = 1000, + MaxRetryDelayMs = 10000, + EnableExponentialBackoff = true, + RequestTimeoutSeconds = 30, + EnableHistoryCompression = true, + CompressionThreshold = 10, + CompressionTarget = 5, + MinMessageLengthForSummarization = 50, + MaxSummarizedMessageLength = 200, + CompressionTimeoutSeconds = 15, + StatusCheckTimeoutSeconds = 5, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result + .Failures.Should() + .Contain(f => f.Contains("Temperature must be between 0.0 and 2.0")); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenSystemPromptPathIsEmpty() + { + // Arrange + var settings = new AISettings + { + Temperature = 0.7, + SystemPromptPath = "", // Invalid: empty + MaxRetryAttempts = 3, + RetryDelayMs = 1000, + MaxRetryDelayMs = 10000, + EnableExponentialBackoff = true, + RequestTimeoutSeconds = 30, + EnableHistoryCompression = true, + CompressionThreshold = 10, + CompressionTarget = 5, + MinMessageLengthForSummarization = 50, + MaxSummarizedMessageLength = 200, + CompressionTimeoutSeconds = 15, + StatusCheckTimeoutSeconds = 5, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("System prompt path cannot be empty")); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenMaxRetryAttemptsIsInvalid() + { + // Arrange + var settings = new AISettings + { + Temperature = 0.7, + SystemPromptPath = "Prompts/system-prompt.txt", + MaxRetryAttempts = 15, // Invalid: > 10 + RetryDelayMs = 1000, + MaxRetryDelayMs = 10000, + EnableExponentialBackoff = true, + RequestTimeoutSeconds = 30, + EnableHistoryCompression = true, + CompressionThreshold = 10, + CompressionTarget = 5, + MinMessageLengthForSummarization = 50, + MaxSummarizedMessageLength = 200, + CompressionTimeoutSeconds = 15, + StatusCheckTimeoutSeconds = 5, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Max retry attempts cannot exceed 10")); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenCompressionSettingsAreInvalid() + { + // Arrange + var settings = new AISettings + { + Temperature = 0.7, + SystemPromptPath = "Prompts/system-prompt.txt", + MaxRetryAttempts = 3, + RetryDelayMs = 1000, + MaxRetryDelayMs = 10000, + EnableExponentialBackoff = true, + RequestTimeoutSeconds = 30, + EnableHistoryCompression = true, + CompressionThreshold = 5, // Invalid: same as target + CompressionTarget = 5, + MinMessageLengthForSummarization = 50, + MaxSummarizedMessageLength = 200, + CompressionTimeoutSeconds = 15, + StatusCheckTimeoutSeconds = 5, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result + .Failures.Should() + .Contain(f => f.Contains("Compression target must be less than compression threshold")); + } +} diff --git a/ChatBot.Tests/Configuration/Validators/DatabaseSettingsValidatorTests.cs b/ChatBot.Tests/Configuration/Validators/DatabaseSettingsValidatorTests.cs new file mode 100644 index 0000000..b92dc76 --- /dev/null +++ b/ChatBot.Tests/Configuration/Validators/DatabaseSettingsValidatorTests.cs @@ -0,0 +1,92 @@ +using ChatBot.Models.Configuration; +using ChatBot.Models.Configuration.Validators; +using FluentAssertions; + +namespace ChatBot.Tests.Configuration.Validators; + +public class DatabaseSettingsValidatorTests +{ + private readonly DatabaseSettingsValidator _validator = new(); + + [Fact] + public void Validate_ShouldReturnSuccess_WhenSettingsAreValid() + { + // Arrange + var settings = new DatabaseSettings + { + ConnectionString = + "Host=localhost;Port=5432;Database=chatbot;Username=user;Password=pass", + CommandTimeout = 30, + EnableSensitiveDataLogging = false, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenConnectionStringIsEmpty() + { + // Arrange + var settings = new DatabaseSettings + { + ConnectionString = "", + CommandTimeout = 30, + EnableSensitiveDataLogging = false, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Database connection string is required")); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenCommandTimeoutIsInvalid() + { + // Arrange + var settings = new DatabaseSettings + { + ConnectionString = + "Host=localhost;Port=5432;Database=chatbot;Username=user;Password=pass", + CommandTimeout = 0, // Invalid: <= 0 + EnableSensitiveDataLogging = false, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Command timeout must be greater than 0")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_ShouldReturnFailure_WhenConnectionStringIsNullOrWhitespace( + string? connectionString + ) + { + // Arrange + var settings = new DatabaseSettings + { + ConnectionString = connectionString!, + CommandTimeout = 30, + EnableSensitiveDataLogging = false, + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Database connection string is required")); + } +} diff --git a/ChatBot.Tests/Configuration/Validators/OllamaSettingsValidatorTests.cs b/ChatBot.Tests/Configuration/Validators/OllamaSettingsValidatorTests.cs new file mode 100644 index 0000000..09465fc --- /dev/null +++ b/ChatBot.Tests/Configuration/Validators/OllamaSettingsValidatorTests.cs @@ -0,0 +1,86 @@ +using ChatBot.Models.Configuration; +using ChatBot.Models.Configuration.Validators; +using FluentAssertions; + +namespace ChatBot.Tests.Configuration.Validators; + +public class OllamaSettingsValidatorTests +{ + private readonly OllamaSettingsValidator _validator = new(); + + [Fact] + public void Validate_ShouldReturnSuccess_WhenSettingsAreValid() + { + // Arrange + var settings = new OllamaSettings + { + Url = "http://localhost:11434", + DefaultModel = "llama3.2", + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenUrlIsEmpty() + { + // Arrange + var settings = new OllamaSettings { Url = "", DefaultModel = "llama3.2" }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Ollama URL is required")); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenUrlIsInvalid() + { + // Arrange + var settings = new OllamaSettings { Url = "invalid-url", DefaultModel = "llama3.2" }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Invalid Ollama URL format")); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenDefaultModelIsEmpty() + { + // Arrange + var settings = new OllamaSettings { Url = "http://localhost:11434", DefaultModel = "" }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("DefaultModel is required")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_ShouldReturnFailure_WhenUrlIsNullOrWhitespace(string? url) + { + // Arrange + var settings = new OllamaSettings { Url = url!, DefaultModel = "llama3.2" }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Ollama URL is required")); + } +} diff --git a/ChatBot.Tests/Configuration/Validators/TelegramBotSettingsValidatorTests.cs b/ChatBot.Tests/Configuration/Validators/TelegramBotSettingsValidatorTests.cs new file mode 100644 index 0000000..d55fa4c --- /dev/null +++ b/ChatBot.Tests/Configuration/Validators/TelegramBotSettingsValidatorTests.cs @@ -0,0 +1,76 @@ +using ChatBot.Models.Configuration; +using ChatBot.Models.Configuration.Validators; +using FluentAssertions; + +namespace ChatBot.Tests.Configuration.Validators; + +public class TelegramBotSettingsValidatorTests +{ + private readonly TelegramBotSettingsValidator _validator = new(); + + [Fact] + public void Validate_ShouldReturnSuccess_WhenSettingsAreValid() + { + // Arrange + var settings = new TelegramBotSettings + { + BotToken = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijk", + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenBotTokenIsEmpty() + { + // Arrange + var settings = new TelegramBotSettings { BotToken = "" }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Telegram bot token is required")); + } + + [Fact] + public void Validate_ShouldReturnFailure_WhenBotTokenIsTooShort() + { + // Arrange + var settings = new TelegramBotSettings + { + BotToken = "1234567890:ABC", // 15 chars, too short + }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result + .Failures.Should() + .Contain(f => f.Contains("Telegram bot token must be at least 40 characters")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_ShouldReturnFailure_WhenBotTokenIsNullOrWhitespace(string? botToken) + { + // Arrange + var settings = new TelegramBotSettings { BotToken = botToken! }; + + // Act + var result = _validator.Validate(null, settings); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().Contain(f => f.Contains("Telegram bot token is required")); + } +} diff --git a/ChatBot.Tests/Data/Repositories/ChatSessionRepositoryTests.cs b/ChatBot.Tests/Data/Repositories/ChatSessionRepositoryTests.cs new file mode 100644 index 0000000..192a98c --- /dev/null +++ b/ChatBot.Tests/Data/Repositories/ChatSessionRepositoryTests.cs @@ -0,0 +1,266 @@ +using ChatBot.Data; +using ChatBot.Data.Interfaces; +using ChatBot.Data.Repositories; +using ChatBot.Models.Entities; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ChatBot.Tests.Data.Repositories; + +public class ChatSessionRepositoryTests : TestBase +{ + private ChatBotDbContext _dbContext = null!; + private ChatSessionRepository _repository = null!; + + public ChatSessionRepositoryTests() + { + SetupServices(); + } + + protected override void ConfigureServices(IServiceCollection services) + { + // Add in-memory database with unique name per test + services.AddDbContext(options => + options.UseInMemoryDatabase(Guid.NewGuid().ToString()) + ); + + // Add logger + services.AddSingleton(Mock.Of>()); + + // Add repository + services.AddScoped(); + } + + protected override void SetupServices() + { + base.SetupServices(); + + _dbContext = ServiceProvider.GetRequiredService(); + _repository = (ChatSessionRepository) + ServiceProvider.GetRequiredService(); + + // Ensure database is created + _dbContext.Database.EnsureCreated(); + } + + private void CleanupDatabase() + { + _dbContext.ChatSessions.RemoveRange(_dbContext.ChatSessions); + _dbContext.ChatMessages.RemoveRange(_dbContext.ChatMessages); + _dbContext.SaveChanges(); + } + + [Fact] + public async Task GetOrCreateAsync_ShouldReturnExistingSession_WhenSessionExists() + { + // Arrange + CleanupDatabase(); + var chatId = 12345L; + var chatType = "private"; + var chatTitle = "Test Chat"; + + var existingSession = TestDataBuilder.Mocks.CreateChatSessionEntity( + 1, + chatId, + "existing-session", + chatType, + chatTitle + ); + _dbContext.ChatSessions.Add(existingSession); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _repository.GetOrCreateAsync(chatId, chatType, chatTitle); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(chatId); + result.SessionId.Should().Be("existing-session"); + } + + [Fact] + public async Task GetOrCreateAsync_ShouldCreateNewSession_WhenSessionDoesNotExist() + { + // Arrange + CleanupDatabase(); + var chatId = 12345L; + var chatType = "private"; + var chatTitle = "Test Chat"; + + // Act + var result = await _repository.GetOrCreateAsync(chatId, chatType, chatTitle); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(chatId); + result.ChatType.Should().Be(chatType); + result.ChatTitle.Should().Be(chatTitle); + result.SessionId.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task GetByChatIdAsync_ShouldReturnSession_WhenSessionExists() + { + // Arrange + CleanupDatabase(); + var chatId = 12345L; + var session = TestDataBuilder.Mocks.CreateChatSessionEntity(1, chatId); + _dbContext.ChatSessions.Add(session); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _repository.GetByChatIdAsync(chatId); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(chatId); + } + + [Fact] + public async Task GetByChatIdAsync_ShouldReturnNull_WhenSessionDoesNotExist() + { + // Arrange + CleanupDatabase(); + var chatId = 12345L; + + // Act + var result = await _repository.GetByChatIdAsync(chatId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetBySessionIdAsync_ShouldReturnSession_WhenSessionExists() + { + // Arrange + CleanupDatabase(); + var sessionId = "test-session"; + var session = TestDataBuilder.Mocks.CreateChatSessionEntity(1, 12345, sessionId); + _dbContext.ChatSessions.Add(session); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _repository.GetBySessionIdAsync(sessionId); + + // Assert + result.Should().NotBeNull(); + result.SessionId.Should().Be(sessionId); + } + + [Fact] + public async Task GetBySessionIdAsync_ShouldReturnNull_WhenSessionDoesNotExist() + { + // Arrange + CleanupDatabase(); + var sessionId = "nonexistent-session"; + + // Act + var result = await _repository.GetBySessionIdAsync(sessionId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task UpdateAsync_ShouldUpdateSession() + { + // Arrange + CleanupDatabase(); + var session = TestDataBuilder.Mocks.CreateChatSessionEntity(1, 12345); + _dbContext.ChatSessions.Add(session); + await _dbContext.SaveChangesAsync(); + + session.ChatTitle = "Updated Title"; + + // Act + var result = await _repository.UpdateAsync(session); + + // Assert + result.Should().NotBeNull(); + result.ChatTitle.Should().Be("Updated Title"); + } + + [Fact] + public async Task DeleteAsync_ShouldReturnTrue_WhenSessionExists() + { + // Arrange + CleanupDatabase(); + var chatId = 12345L; + var session = TestDataBuilder.Mocks.CreateChatSessionEntity(1, chatId); + _dbContext.ChatSessions.Add(session); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _repository.DeleteAsync(chatId); + + // Assert + result.Should().BeTrue(); + var deletedSession = await _repository.GetByChatIdAsync(chatId); + deletedSession.Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_ShouldReturnFalse_WhenSessionDoesNotExist() + { + // Arrange + CleanupDatabase(); + var chatId = 12345L; + + // Act + var result = await _repository.DeleteAsync(chatId); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task GetActiveSessionsCountAsync_ShouldReturnCorrectCount() + { + // Arrange + CleanupDatabase(); + var session1 = TestDataBuilder.Mocks.CreateChatSessionEntity(1, 12345); + var session2 = TestDataBuilder.Mocks.CreateChatSessionEntity(2, 12346); + _dbContext.ChatSessions.AddRange(session1, session2); + await _dbContext.SaveChangesAsync(); + + // Act + var count = await _repository.GetActiveSessionsCountAsync(); + + // Assert + count.Should().Be(2); + } + + [Fact] + public async Task CleanupOldSessionsAsync_ShouldRemoveOldSessions() + { + // Arrange + CleanupDatabase(); + + // Verify database is empty + var initialCount = await _repository.GetActiveSessionsCountAsync(); + initialCount.Should().Be(0); + + var oldSession = TestDataBuilder.Mocks.CreateChatSessionEntity(1, 12345); + oldSession.LastUpdatedAt = DateTime.UtcNow.AddDays(-2); // 2 days old + + var recentSession = TestDataBuilder.Mocks.CreateChatSessionEntity(2, 12346); + recentSession.LastUpdatedAt = DateTime.UtcNow.AddMinutes(-30); // 30 minutes old + + _dbContext.ChatSessions.AddRange(oldSession, recentSession); + await _dbContext.SaveChangesAsync(); + + // Act + var removedCount = await _repository.CleanupOldSessionsAsync(1); // Remove sessions older than 1 hour + + // Assert + removedCount.Should().Be(1); + var remainingSessions = await _repository.GetActiveSessionsCountAsync(); + remainingSessions.Should().Be(1); + } +} diff --git a/ChatBot.Tests/Integration/ChatServiceIntegrationTests.cs b/ChatBot.Tests/Integration/ChatServiceIntegrationTests.cs new file mode 100644 index 0000000..7c70ef0 --- /dev/null +++ b/ChatBot.Tests/Integration/ChatServiceIntegrationTests.cs @@ -0,0 +1,246 @@ +using System.Linq; +using ChatBot.Models; +using ChatBot.Models.Configuration; +using ChatBot.Models.Dto; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Tests.Integration; + +public class ChatServiceIntegrationTests : TestBase +{ + private ChatService _chatService = null!; + private Mock _sessionStorageMock = null!; + private Mock _aiServiceMock = null!; + + public ChatServiceIntegrationTests() + { + SetupServices(); + } + + protected override void ConfigureServices(IServiceCollection services) + { + // Add mocked services + _sessionStorageMock = TestDataBuilder.Mocks.CreateSessionStorageMock(); + _aiServiceMock = TestDataBuilder.Mocks.CreateAIServiceMock(); + + services.AddSingleton(_sessionStorageMock.Object); + services.AddSingleton(_aiServiceMock.Object); + services.AddSingleton(Mock.Of>()); + services.AddSingleton( + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateAISettings()) + .Object + ); + services.AddSingleton(TestDataBuilder.Mocks.CreateCompressionServiceMock().Object); + services.AddScoped(); + } + + protected override void SetupServices() + { + base.SetupServices(); + _chatService = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldProcessUserMessage_WhenSessionExists() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello, how are you?"; + var expectedResponse = "I'm doing well, thank you!"; + + var session = TestDataBuilder.ChatSessions.CreateBasicSession(chatId); + _sessionStorageMock.Setup(x => x.GetOrCreate(chatId, "private", "")).Returns(session); + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(expectedResponse); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().Be(expectedResponse); + session.GetAllMessages().Should().HaveCount(2); // User message + AI response + session.GetAllMessages()[0].Content.Should().Be(message); + session.GetAllMessages()[0].Role.Should().Be(ChatRole.User); + session.GetAllMessages()[1].Content.Should().Be(expectedResponse); + session.GetAllMessages()[1].Role.Should().Be(ChatRole.Assistant); + + _sessionStorageMock.Verify(x => x.SaveSessionAsync(session), Times.Exactly(2)); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldCreateNewSession_WhenSessionDoesNotExist() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello"; + var expectedResponse = "Hi there!"; + + _sessionStorageMock + .Setup(x => x.GetOrCreate(chatId, "private", "")) + .Returns(TestDataBuilder.ChatSessions.CreateBasicSession(chatId)); + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(expectedResponse); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().Be(expectedResponse); + _sessionStorageMock.Verify(x => x.GetOrCreate(chatId, "private", ""), Times.Once); + _sessionStorageMock.Verify( + x => x.SaveSessionAsync(It.IsAny()), + Times.Exactly(2) + ); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldHandleEmptyMessage() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = ""; + var expectedResponse = "I didn't receive a message. Could you please try again?"; + + var session = TestDataBuilder.ChatSessions.CreateBasicSession(chatId); + _sessionStorageMock.Setup(x => x.GetOrCreate(chatId, "private", "")).Returns(session); + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(expectedResponse); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().Be(expectedResponse); + session.GetAllMessages().Should().HaveCount(2); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldHandleAIServiceException() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello"; + + var session = TestDataBuilder.ChatSessions.CreateBasicSession(chatId); + _sessionStorageMock.Setup(x => x.GetOrCreate(chatId, "private", "")).Returns(session); + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ThrowsAsync(new Exception("AI service error")); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert - should return error message instead of throwing + result.Should().Contain("ошибка"); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldUseCompression_WhenEnabled() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello"; + var expectedResponse = "Hi there!"; + + var session = TestDataBuilder.ChatSessions.CreateSessionWithMessages(chatId, 10); // 10 messages + _sessionStorageMock.Setup(x => x.GetOrCreate(chatId, "private", "")).Returns(session); + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(expectedResponse); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().Be(expectedResponse); + _aiServiceMock.Verify( + x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task ClearHistoryAsync_ShouldClearSessionMessages() + { + // Arrange + var chatId = 12345L; + var session = TestDataBuilder.ChatSessions.CreateSessionWithMessages(chatId, 5); + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns(session); + + // Act + await _chatService.ClearHistoryAsync(chatId); + + // Assert + session.GetAllMessages().Should().BeEmpty(); + _sessionStorageMock.Verify(x => x.SaveSessionAsync(session), Times.Once); + } + + [Fact] + public async Task ClearHistoryAsync_ShouldHandleNonExistentSession() + { + // Arrange + var chatId = 12345L; + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns((ChatBot.Models.ChatSession?)null); + + // Act + await _chatService.ClearHistoryAsync(chatId); + + // Assert + _sessionStorageMock.Verify( + x => x.SaveSessionAsync(It.IsAny()), + Times.Never + ); + } +} diff --git a/ChatBot.Tests/Models/ChatSessionTests.cs b/ChatBot.Tests/Models/ChatSessionTests.cs new file mode 100644 index 0000000..f4dda6f --- /dev/null +++ b/ChatBot.Tests/Models/ChatSessionTests.cs @@ -0,0 +1,186 @@ +using ChatBot.Models; +using ChatBot.Models.Dto; +using FluentAssertions; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Tests.Models; + +public class ChatSessionTests +{ + [Fact] + public void Constructor_ShouldInitializeProperties() + { + // Arrange & Act + var session = new ChatSession(); + + // Assert + session.ChatId.Should().Be(0); + session.SessionId.Should().NotBeNullOrEmpty(); + session.ChatType.Should().Be("private"); + session.ChatTitle.Should().Be(string.Empty); + session.Model.Should().Be(string.Empty); + session.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + session.LastUpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + session.GetAllMessages().Should().NotBeNull(); + session.GetAllMessages().Should().BeEmpty(); + } + + [Fact] + public void AddUserMessage_ShouldAddMessageToSession() + { + // Arrange + var session = new ChatSession(); + var content = "Hello"; + var username = "testuser"; + + // Act + session.AddUserMessage(content, username); + + // Assert + session.GetAllMessages().Should().HaveCount(1); + var message = session.GetAllMessages().First(); + message.Role.Should().Be(ChatRole.User); + message.Content.Should().Be(content); + } + + [Fact] + public void AddAssistantMessage_ShouldAddMessageToSession() + { + // Arrange + var session = new ChatSession(); + var content = "Hello! How can I help you?"; + + // Act + session.AddAssistantMessage(content); + + // Assert + session.GetAllMessages().Should().HaveCount(1); + var message = session.GetAllMessages().First(); + message.Role.Should().Be(ChatRole.Assistant); + message.Content.Should().Be(content); + } + + [Fact] + public void AddMessage_ShouldAddMessageToSession() + { + // Arrange + var session = new ChatSession(); + var content = "You are a helpful assistant."; + var message = new ChatMessage { Role = ChatRole.System, Content = content }; + + // Act + session.AddMessage(message); + + // Assert + session.GetAllMessages().Should().HaveCount(1); + var addedMessage = session.GetAllMessages().First(); + addedMessage.Role.Should().Be(ChatRole.System); + addedMessage.Content.Should().Be(content); + } + + [Fact] + public void ClearHistory_ShouldRemoveAllMessages() + { + // Arrange + var session = new ChatSession(); + session.AddUserMessage("Hello", "testuser"); + session.AddAssistantMessage("Hi there!"); + + // Act + session.ClearHistory(); + + // Assert + session.GetAllMessages().Should().BeEmpty(); + } + + [Fact] + public void GetMessageCount_ShouldReturnCorrectCount() + { + // Arrange + var session = new ChatSession(); + session.AddUserMessage("Hello", "testuser"); + session.AddAssistantMessage("Hi there!"); + session.AddUserMessage("How are you?", "testuser"); + + // Act + var count = session.GetMessageCount(); + + // Assert + count.Should().Be(3); + } + + [Fact] + public void GetLastMessage_ShouldReturnLastMessage() + { + // Arrange + var session = new ChatSession(); + session.AddUserMessage("Hello", "testuser"); + session.AddAssistantMessage("Hi there!"); + session.AddUserMessage("How are you?", "testuser"); + + // Act + var messages = session.GetAllMessages(); + var lastMessage = messages.LastOrDefault(); + + // Assert + lastMessage.Should().NotBeNull(); + lastMessage!.Content.Should().Be("How are you?"); + lastMessage.Role.Should().Be(ChatRole.User); + } + + [Fact] + public void GetLastMessage_ShouldReturnNull_WhenNoMessages() + { + // Arrange + var session = new ChatSession(); + + // Act + var messages = session.GetAllMessages(); + var lastMessage = messages.LastOrDefault(); + + // Assert + lastMessage.Should().BeNull(); + } + + [Fact] + public void AddMessage_ShouldUpdateLastUpdatedAt() + { + // Arrange + var session = new ChatSession(); + var originalTime = session.LastUpdatedAt; + var message = new ChatMessage { Role = ChatRole.User, Content = "Test" }; + + // Act + session.AddMessage(message); + + // Assert + session.LastUpdatedAt.Should().BeAfter(originalTime); + } + + [Fact] + public void GetMessageCount_ShouldReturnZero_WhenNoMessages() + { + // Arrange + var session = new ChatSession(); + + // Act + var count = session.GetMessageCount(); + + // Assert + count.Should().Be(0); + } + + [Fact] + public void GetMessageCount_ShouldReturnCorrectCount_WhenHasMessages() + { + // Arrange + var session = new ChatSession(); + session.AddUserMessage("Hello", "testuser"); + + // Act + var count = session.GetMessageCount(); + + // Assert + count.Should().Be(1); + } +} diff --git a/ChatBot.Tests/Services/AIServiceTests.cs b/ChatBot.Tests/Services/AIServiceTests.cs new file mode 100644 index 0000000..bb54861 --- /dev/null +++ b/ChatBot.Tests/Services/AIServiceTests.cs @@ -0,0 +1,209 @@ +using System.Linq; +using ChatBot.Common.Constants; +using ChatBot.Models.Configuration; +using ChatBot.Models.Dto; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Tests.Services; + +public class AIServiceTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly Mock _modelServiceMock; + private readonly Mock _ollamaClientMock; + private readonly Mock _systemPromptServiceMock; + private readonly Mock _compressionServiceMock; + private readonly AISettings _aiSettings; + private readonly AIService _aiService; + + public AIServiceTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + var ollamaSettings = TestDataBuilder.Configurations.CreateOllamaSettings(); + var ollamaOptionsMock = TestDataBuilder.Mocks.CreateOptionsMock(ollamaSettings); + _modelServiceMock = new Mock( + Mock.Of>(), + ollamaOptionsMock.Object + ); + _ollamaClientMock = TestDataBuilder.Mocks.CreateOllamaClientMock(); + _systemPromptServiceMock = new Mock( + Mock.Of>(), + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateAISettings()) + .Object + ); + _compressionServiceMock = TestDataBuilder.Mocks.CreateCompressionServiceMock(); + _aiSettings = TestDataBuilder.Configurations.CreateAISettings(); + + var optionsMock = TestDataBuilder.Mocks.CreateOptionsMock(_aiSettings); + + _aiService = new AIService( + _loggerMock.Object, + _modelServiceMock.Object, + _ollamaClientMock.Object, + optionsMock.Object, + _systemPromptServiceMock.Object, + _compressionServiceMock.Object + ); + } + + [Fact] + public async Task GenerateChatCompletionAsync_ShouldReturnResponse_WhenSuccessful() + { + // Arrange + var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(2); + var expectedResponse = "Test AI response"; + var model = "llama3.2"; + + _modelServiceMock.Setup(x => x.GetCurrentModel()).Returns(model); + _systemPromptServiceMock.Setup(x => x.GetSystemPromptAsync()).ReturnsAsync("System prompt"); + + var responseBuilder = new System.Text.StringBuilder(); + responseBuilder.Append(expectedResponse); + + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Returns( + TestDataBuilder.Mocks.CreateAsyncEnumerable( + new List + { + new OllamaSharp.Models.Chat.ChatResponseStream + { + Message = new Message(ChatRole.Assistant, expectedResponse), + }, + } + ) + ); + + // Act + var result = await _aiService.GenerateChatCompletionAsync(messages); + + // Assert + result.Should().Be(expectedResponse); + _ollamaClientMock.Verify( + x => x.ChatAsync(It.IsAny()), + Times.Once + ); + } + + [Fact] + public async Task GenerateChatCompletionAsync_ShouldThrowException_WhenOllamaClientThrows() + { + // Arrange + var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(2); + var model = "llama3.2"; + + _modelServiceMock.Setup(x => x.GetCurrentModel()).Returns(model); + _systemPromptServiceMock.Setup(x => x.GetSystemPromptAsync()).ReturnsAsync("System prompt"); + + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Throws(new Exception("Ollama client error")); + + // Act & Assert + var result = await _aiService.GenerateChatCompletionAsync(messages); + result.Should().Be(AIResponseConstants.DefaultErrorMessage); + } + + [Fact] + public async Task GenerateChatCompletionWithCompressionAsync_ShouldUseCompression_WhenEnabled() + { + // Arrange + var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); + var expectedResponse = "Test AI response with compression"; + var model = "llama3.2"; + + _modelServiceMock.Setup(x => x.GetCurrentModel()).Returns(model); + _systemPromptServiceMock.Setup(x => x.GetSystemPromptAsync()).ReturnsAsync("System prompt"); + _compressionServiceMock.Setup(x => x.ShouldCompress(20, 10)).Returns(true); + _compressionServiceMock + .Setup(x => + x.CompressHistoryAsync( + It.IsAny>(), + 5, + It.IsAny() + ) + ) + .ReturnsAsync(messages.TakeLast(5).ToList()); + + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Returns( + TestDataBuilder.Mocks.CreateAsyncEnumerable( + new List + { + new OllamaSharp.Models.Chat.ChatResponseStream + { + Message = new Message(ChatRole.Assistant, expectedResponse), + }, + } + ) + ); + + // Act + var result = await _aiService.GenerateChatCompletionWithCompressionAsync(messages); + + // Assert + result.Should().Be(expectedResponse); + _compressionServiceMock.Verify(x => x.ShouldCompress(20, 10), Times.Once); + _compressionServiceMock.Verify( + x => + x.CompressHistoryAsync( + It.IsAny>(), + 5, + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task GenerateChatCompletionWithCompressionAsync_ShouldNotUseCompression_WhenNotNeeded() + { + // Arrange + var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(3); + var expectedResponse = "Test AI response without compression"; + var model = "llama3.2"; + + _modelServiceMock.Setup(x => x.GetCurrentModel()).Returns(model); + _systemPromptServiceMock.Setup(x => x.GetSystemPromptAsync()).ReturnsAsync("System prompt"); + _compressionServiceMock.Setup(x => x.ShouldCompress(6, 10)).Returns(false); + + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Returns( + TestDataBuilder.Mocks.CreateAsyncEnumerable( + new List + { + new OllamaSharp.Models.Chat.ChatResponseStream + { + Message = new Message(ChatRole.Assistant, expectedResponse), + }, + } + ) + ); + + // Act + var result = await _aiService.GenerateChatCompletionWithCompressionAsync(messages); + + // Assert + result.Should().Be(expectedResponse); + _compressionServiceMock.Verify(x => x.ShouldCompress(6, 10), Times.Once); + _compressionServiceMock.Verify( + x => + x.CompressHistoryAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } +} diff --git a/ChatBot.Tests/Services/ChatServiceTests.cs b/ChatBot.Tests/Services/ChatServiceTests.cs new file mode 100644 index 0000000..2149fe0 --- /dev/null +++ b/ChatBot.Tests/Services/ChatServiceTests.cs @@ -0,0 +1,321 @@ +using ChatBot.Models; +using ChatBot.Models.Configuration; +using ChatBot.Models.Dto; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace ChatBot.Tests.Services; + +public class ChatServiceTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly Mock _aiServiceMock; + private readonly Mock _sessionStorageMock; + private readonly Mock _compressionServiceMock; + private readonly AISettings _aiSettings; + private readonly ChatService _chatService; + + public ChatServiceTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + _aiServiceMock = TestDataBuilder.Mocks.CreateAIServiceMock(); + _sessionStorageMock = TestDataBuilder.Mocks.CreateSessionStorageMock(); + _compressionServiceMock = TestDataBuilder.Mocks.CreateCompressionServiceMock(); + _aiSettings = TestDataBuilder.Configurations.CreateAISettings(); + + var optionsMock = TestDataBuilder.Mocks.CreateOptionsMock(_aiSettings); + + _chatService = new ChatService( + _loggerMock.Object, + _aiServiceMock.Object, + _sessionStorageMock.Object, + optionsMock.Object, + _compressionServiceMock.Object + ); + } + + [Fact] + public void GetOrCreateSession_ShouldCreateNewSession_WhenSessionDoesNotExist() + { + // Arrange + var chatId = 12345L; + var chatType = "private"; + var chatTitle = "Test Chat"; + + // Act + var session = _chatService.GetOrCreateSession(chatId, chatType, chatTitle); + + // Assert + session.Should().NotBeNull(); + session.ChatId.Should().Be(chatId); + session.ChatType.Should().Be(chatType); + session.ChatTitle.Should().Be(chatTitle); + + _sessionStorageMock.Verify(x => x.GetOrCreate(chatId, chatType, chatTitle), Times.Once); + } + + [Fact] + public void GetOrCreateSession_ShouldSetCompressionService_WhenCompressionIsEnabled() + { + // Arrange + var chatId = 12345L; + _aiSettings.EnableHistoryCompression = true; + + // Act + var session = _chatService.GetOrCreateSession(chatId); + + // Assert + session.Should().NotBeNull(); + _sessionStorageMock.Verify(x => x.GetOrCreate(chatId, "private", ""), Times.Once); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldProcessMessageSuccessfully_WhenValidInput() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello, bot!"; + var expectedResponse = "Hello! How can I help you?"; + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(expectedResponse); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().Be(expectedResponse); + _sessionStorageMock.Verify( + x => x.SaveSessionAsync(It.IsAny()), + Times.AtLeastOnce + ); + _aiServiceMock.Verify( + x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldReturnEmptyString_WhenAIResponseIsEmptyMarker() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello, bot!"; + var emptyResponse = "{empty}"; + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync(emptyResponse); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldReturnErrorMessage_WhenAIResponseIsNull() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello, bot!"; + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync((string)null!); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().Be("Извините, произошла ошибка при генерации ответа."); + } + + [Fact] + public async Task ProcessMessageAsync_ShouldHandleException_WhenErrorOccurs() + { + // Arrange + var chatId = 12345L; + var username = "testuser"; + var message = "Hello, bot!"; + + _aiServiceMock + .Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ThrowsAsync(new Exception("Test exception")); + + // Act + var result = await _chatService.ProcessMessageAsync(chatId, username, message); + + // Assert + result.Should().Be("Извините, произошла ошибка при обработке вашего сообщения."); + // Verify that error was logged + // In a real test, we would verify the logger calls + } + + [Fact] + public async Task UpdateSessionParametersAsync_ShouldUpdateSession_WhenSessionExists() + { + // Arrange + var chatId = 12345L; + var newModel = "llama3.2"; + var session = TestDataBuilder.ChatSessions.CreateBasicSession(chatId); + + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns(session); + + // Act + await _chatService.UpdateSessionParametersAsync(chatId, newModel); + + // Assert + session.Model.Should().Be(newModel); + _sessionStorageMock.Verify(x => x.SaveSessionAsync(session), Times.Once); + } + + [Fact] + public async Task UpdateSessionParametersAsync_ShouldNotUpdate_WhenSessionDoesNotExist() + { + // Arrange + var chatId = 12345L; + var newModel = "llama3.2"; + + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns((ChatBot.Models.ChatSession?)null); + + // Act + await _chatService.UpdateSessionParametersAsync(chatId, newModel); + + // Assert + _sessionStorageMock.Verify( + x => x.SaveSessionAsync(It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task ClearHistoryAsync_ShouldClearHistory_WhenSessionExists() + { + // Arrange + var chatId = 12345L; + var session = TestDataBuilder.ChatSessions.CreateSessionWithMessages(chatId, 5); + + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns(session); + + // Act + await _chatService.ClearHistoryAsync(chatId); + + // Assert + session.GetMessageCount().Should().Be(0); + _sessionStorageMock.Verify(x => x.SaveSessionAsync(session), Times.Once); + } + + [Fact] + public void GetSession_ShouldReturnSession_WhenSessionExists() + { + // Arrange + var chatId = 12345L; + var session = TestDataBuilder.ChatSessions.CreateBasicSession(chatId); + + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns(session); + + // Act + var result = _chatService.GetSession(chatId); + + // Assert + result.Should().Be(session); + } + + [Fact] + public void GetSession_ShouldReturnNull_WhenSessionDoesNotExist() + { + // Arrange + var chatId = 12345L; + + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns((ChatBot.Models.ChatSession?)null); + + // Act + var result = _chatService.GetSession(chatId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void RemoveSession_ShouldReturnTrue_WhenSessionExists() + { + // Arrange + var chatId = 12345L; + + _sessionStorageMock.Setup(x => x.Remove(chatId)).Returns(true); + + // Act + var result = _chatService.RemoveSession(chatId); + + // Assert + result.Should().BeTrue(); + _sessionStorageMock.Verify(x => x.Remove(chatId), Times.Once); + } + + [Fact] + public void GetActiveSessionsCount_ShouldReturnCorrectCount() + { + // Arrange + var expectedCount = 5; + + _sessionStorageMock.Setup(x => x.GetActiveSessionsCount()).Returns(expectedCount); + + // Act + var result = _chatService.GetActiveSessionsCount(); + + // Assert + result.Should().Be(expectedCount); + } + + [Fact] + public void CleanupOldSessions_ShouldReturnCleanedCount() + { + // Arrange + var hoursOld = 24; + var expectedCleaned = 3; + + _sessionStorageMock.Setup(x => x.CleanupOldSessions(hoursOld)).Returns(expectedCleaned); + + // Act + var result = _chatService.CleanupOldSessions(hoursOld); + + // Assert + result.Should().Be(expectedCleaned); + _sessionStorageMock.Verify(x => x.CleanupOldSessions(hoursOld), Times.Once); + } +} diff --git a/ChatBot.Tests/Services/DatabaseSessionStorageTests.cs b/ChatBot.Tests/Services/DatabaseSessionStorageTests.cs new file mode 100644 index 0000000..3d0840a --- /dev/null +++ b/ChatBot.Tests/Services/DatabaseSessionStorageTests.cs @@ -0,0 +1,182 @@ +using ChatBot.Data; +using ChatBot.Data.Interfaces; +using ChatBot.Data.Repositories; +using ChatBot.Models; +using ChatBot.Models.Entities; +using ChatBot.Services; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ChatBot.Tests.Services; + +public class DatabaseSessionStorageTests : TestBase +{ + private ChatBotDbContext _dbContext = null!; + private DatabaseSessionStorage _sessionStorage = null!; + private Mock _repositoryMock = null!; + + public DatabaseSessionStorageTests() + { + SetupServices(); + } + + protected override void ConfigureServices(IServiceCollection services) + { + // Add in-memory database + services.AddDbContext(options => + options.UseInMemoryDatabase("TestDatabase") + ); + + // Add mocked repository + _repositoryMock = new Mock(); + services.AddSingleton(_repositoryMock.Object); + + // Add logger + services.AddSingleton(Mock.Of>()); + + // Add session storage + services.AddScoped(); + } + + protected override void SetupServices() + { + base.SetupServices(); + + _dbContext = ServiceProvider.GetRequiredService(); + _sessionStorage = ServiceProvider.GetRequiredService(); + + // Ensure database is created + _dbContext.Database.EnsureCreated(); + } + + [Fact] + public void GetOrCreate_ShouldReturnExistingSession_WhenSessionExists() + { + // Arrange + var existingSession = TestDataBuilder.Mocks.CreateChatSessionEntity(); + _repositoryMock + .Setup(x => x.GetOrCreateAsync(12345, "private", "Test Chat")) + .ReturnsAsync(existingSession); + + // Act + var result = _sessionStorage.GetOrCreate(12345, "private", "Test Chat"); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(12345); + _repositoryMock.Verify(x => x.GetOrCreateAsync(12345, "private", "Test Chat"), Times.Once); + } + + [Fact] + public void Get_ShouldReturnSession_WhenSessionExists() + { + // Arrange + var sessionEntity = TestDataBuilder.Mocks.CreateChatSessionEntity(); + _repositoryMock.Setup(x => x.GetByChatIdAsync(12345)).ReturnsAsync(sessionEntity); + + // Act + var result = _sessionStorage.Get(12345); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(12345); + _repositoryMock.Verify(x => x.GetByChatIdAsync(12345), Times.Once); + } + + [Fact] + public void Get_ShouldReturnNull_WhenSessionDoesNotExist() + { + // Arrange + _repositoryMock + .Setup(x => x.GetByChatIdAsync(12345)) + .ReturnsAsync((ChatSessionEntity?)null); + + // Act + var result = _sessionStorage.Get(12345); + + // Assert + result.Should().BeNull(); + _repositoryMock.Verify(x => x.GetByChatIdAsync(12345), Times.Once); + } + + [Fact] + public async Task SaveSessionAsync_ShouldUpdateSession_WhenSessionExists() + { + // Arrange + var session = TestDataBuilder.ChatSessions.CreateBasicSession(12345, "private"); + var sessionEntity = TestDataBuilder.Mocks.CreateChatSessionEntity(); + _repositoryMock.Setup(x => x.GetByChatIdAsync(12345)).ReturnsAsync(sessionEntity); + _repositoryMock + .Setup(x => x.UpdateAsync(It.IsAny())) + .ReturnsAsync(sessionEntity); + + // Act + await _sessionStorage.SaveSessionAsync(session); + + // Assert + _repositoryMock.Verify(x => x.UpdateAsync(It.IsAny()), Times.Once); + } + + [Fact] + public void Remove_ShouldReturnTrue_WhenSessionExists() + { + // Arrange + _repositoryMock.Setup(x => x.DeleteAsync(12345)).ReturnsAsync(true); + + // Act + var result = _sessionStorage.Remove(12345); + + // Assert + result.Should().BeTrue(); + _repositoryMock.Verify(x => x.DeleteAsync(12345), Times.Once); + } + + [Fact] + public void Remove_ShouldReturnFalse_WhenSessionDoesNotExist() + { + // Arrange + _repositoryMock.Setup(x => x.DeleteAsync(12345)).ReturnsAsync(false); + + // Act + var result = _sessionStorage.Remove(12345); + + // Assert + result.Should().BeFalse(); + _repositoryMock.Verify(x => x.DeleteAsync(12345), Times.Once); + } + + [Fact] + public void GetActiveSessionsCount_ShouldReturnCorrectCount() + { + // Arrange + var expectedCount = 5; + _repositoryMock.Setup(x => x.GetActiveSessionsCountAsync()).ReturnsAsync(expectedCount); + + // Act + var result = _sessionStorage.GetActiveSessionsCount(); + + // Assert + result.Should().Be(expectedCount); + _repositoryMock.Verify(x => x.GetActiveSessionsCountAsync(), Times.Once); + } + + [Fact] + public void CleanupOldSessions_ShouldReturnCorrectCount() + { + // Arrange + var expectedCount = 3; + _repositoryMock.Setup(x => x.CleanupOldSessionsAsync(24)).ReturnsAsync(expectedCount); + + // Act + var result = _sessionStorage.CleanupOldSessions(24); + + // Assert + result.Should().Be(expectedCount); + _repositoryMock.Verify(x => x.CleanupOldSessionsAsync(24), Times.Once); + } +} diff --git a/ChatBot.Tests/Services/HealthChecks/OllamaHealthCheckTests.cs b/ChatBot.Tests/Services/HealthChecks/OllamaHealthCheckTests.cs new file mode 100644 index 0000000..a439dfe --- /dev/null +++ b/ChatBot.Tests/Services/HealthChecks/OllamaHealthCheckTests.cs @@ -0,0 +1,101 @@ +using System.Linq; +using ChatBot.Models.Configuration; +using ChatBot.Services.HealthChecks; +using ChatBot.Services.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Tests.Services.HealthChecks; + +public class OllamaHealthCheckTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly Mock _ollamaClientMock; + private readonly Mock> _optionsMock; + private readonly OllamaHealthCheck _healthCheck; + + public OllamaHealthCheckTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + _ollamaClientMock = TestDataBuilder.Mocks.CreateOllamaClientMock(); + + var ollamaSettings = TestDataBuilder.Configurations.CreateOllamaSettings(); + _optionsMock = TestDataBuilder.Mocks.CreateOptionsMock(ollamaSettings); + + _healthCheck = new OllamaHealthCheck(_ollamaClientMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnHealthy_WhenOllamaResponds() + { + // Arrange + _ollamaClientMock + .Setup(x => x.ListLocalModelsAsync()) + .ReturnsAsync( + new List + { + new OllamaSharp.Models.Model { Name = "llama3.2" }, + } + ); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnUnhealthy_WhenOllamaThrowsException() + { + // Arrange + _ollamaClientMock + .Setup(x => x.ListLocalModelsAsync()) + .ThrowsAsync(new Exception("Ollama unavailable")); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnUnhealthy_WhenOllamaReturnsEmptyResponse() + { + // Arrange + _ollamaClientMock + .Setup(x => x.ListLocalModelsAsync()) + .ReturnsAsync(new List()); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnUnhealthy_WhenOllamaReturnsNullMessage() + { + // Arrange + _ollamaClientMock + .Setup(x => x.ListLocalModelsAsync()) + .ReturnsAsync((IEnumerable)null!); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Unhealthy); + } +} diff --git a/ChatBot.Tests/Services/HealthChecks/TelegramBotHealthCheckTests.cs b/ChatBot.Tests/Services/HealthChecks/TelegramBotHealthCheckTests.cs new file mode 100644 index 0000000..a5d6d03 --- /dev/null +++ b/ChatBot.Tests/Services/HealthChecks/TelegramBotHealthCheckTests.cs @@ -0,0 +1,101 @@ +using ChatBot.Models.Configuration; +using ChatBot.Services.HealthChecks; +using ChatBot.Services.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Telegram.Bot.Types; + +namespace ChatBot.Tests.Services.HealthChecks; + +public class TelegramBotHealthCheckTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly Mock _telegramBotClientWrapperMock; + private readonly TelegramBotHealthCheck _healthCheck; + + public TelegramBotHealthCheckTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + _telegramBotClientWrapperMock = new Mock(); + + _healthCheck = new TelegramBotHealthCheck( + _telegramBotClientWrapperMock.Object, + _loggerMock.Object + ); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnHealthy_WhenTelegramResponds() + { + // Arrange + var botInfo = TestDataBuilder.Mocks.CreateTelegramBot(); + _telegramBotClientWrapperMock + .Setup(x => x.GetMeAsync(It.IsAny())) + .ReturnsAsync(botInfo); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnUnhealthy_WhenTelegramThrowsException() + { + // Arrange + _telegramBotClientWrapperMock + .Setup(x => x.GetMeAsync(It.IsAny())) + .ThrowsAsync(new Exception("Telegram unavailable")); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnUnhealthy_WhenTelegramReturnsNull() + { + // Arrange + _telegramBotClientWrapperMock + .Setup(x => x.GetMeAsync(It.IsAny())) + .ReturnsAsync((User)null!); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + [Fact] + public async Task CheckHealthAsync_ShouldReturnUnhealthy_WhenTelegramReturnsInvalidBot() + { + // Arrange + var invalidBot = new User + { + Id = 0, // Invalid bot ID + Username = null, + }; + + _telegramBotClientWrapperMock + .Setup(x => x.GetMeAsync(It.IsAny())) + .ReturnsAsync(invalidBot); + + // Act + var context = new HealthCheckContext(); + var result = await _healthCheck.CheckHealthAsync(context); + + // Assert + result.Status.Should().Be(HealthStatus.Unhealthy); + } +} diff --git a/ChatBot.Tests/Services/HistoryCompressionServiceTests.cs b/ChatBot.Tests/Services/HistoryCompressionServiceTests.cs new file mode 100644 index 0000000..5deb067 --- /dev/null +++ b/ChatBot.Tests/Services/HistoryCompressionServiceTests.cs @@ -0,0 +1,224 @@ +using System.Linq; +using ChatBot.Models.Configuration; +using ChatBot.Models.Dto; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Tests.Services; + +public class HistoryCompressionServiceTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly Mock _ollamaClientMock; + private readonly AISettings _aiSettings; + private readonly HistoryCompressionService _compressionService; + + public HistoryCompressionServiceTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + _ollamaClientMock = TestDataBuilder.Mocks.CreateOllamaClientMock(); + _aiSettings = TestDataBuilder.Configurations.CreateAISettings(); + + var optionsMock = TestDataBuilder.Mocks.CreateOptionsMock(_aiSettings); + + _compressionService = new HistoryCompressionService( + _loggerMock.Object, + optionsMock.Object, + _ollamaClientMock.Object + ); + } + + [Fact] + public void ShouldCompress_ShouldReturnTrue_WhenMessageCountExceedsThreshold() + { + // Arrange + var messageCount = 15; + var threshold = 10; + + // Act + var result = _compressionService.ShouldCompress(messageCount, threshold); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldCompress_ShouldReturnFalse_WhenMessageCountIsBelowThreshold() + { + // Arrange + var messageCount = 5; + var threshold = 10; + + // Act + var result = _compressionService.ShouldCompress(messageCount, threshold); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldCompress_ShouldReturnFalse_WhenMessageCountEqualsThreshold() + { + // Arrange + var messageCount = 10; + var threshold = 10; + + // Act + var result = _compressionService.ShouldCompress(messageCount, threshold); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task CompressHistoryAsync_ShouldReturnCompressedMessages_WhenSuccessful() + { + // Arrange + var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); + var targetCount = 5; + var expectedResponse = "Compressed summary of previous messages"; + + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Returns( + TestDataBuilder.Mocks.CreateAsyncEnumerable( + new List + { + new OllamaSharp.Models.Chat.ChatResponseStream + { + Message = new Message(ChatRole.Assistant, expectedResponse), + }, + } + ) + ); + + // Act + var result = await _compressionService.CompressHistoryAsync(messages, targetCount); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(7); // 2 compressed messages + 5 recent messages + result.Should().Contain(m => m.Role == ChatRole.User && m.Content.Contains("[Сжато:")); + result.Should().Contain(m => m.Role == ChatRole.Assistant && m.Content.Contains("[Сжато:")); + result.Should().Contain(m => m.Role == ChatRole.User && m.Content == "User message 9"); + result + .Should() + .Contain(m => m.Role == ChatRole.Assistant && m.Content == "Assistant response 9"); + } + + [Fact] + public async Task CompressHistoryAsync_ShouldFallbackToSimpleTrimming_WhenOllamaClientThrows() + { + // Arrange + var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(10); + var targetCount = 5; + + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Returns(ThrowAsyncEnumerable(new Exception("Ollama client error"))); + + // Act + var result = await _compressionService.CompressHistoryAsync(messages, targetCount); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(7); // 2 compressed messages + 5 recent messages (exception is caught and handled) + result.Should().Contain(m => m.Role == ChatRole.User && m.Content.Contains("[Сжато:")); + result.Should().Contain(m => m.Role == ChatRole.Assistant && m.Content.Contains("[Сжато:")); + result.Should().Contain(m => m.Role == ChatRole.User && m.Content == "User message 9"); + result + .Should() + .Contain(m => m.Role == ChatRole.Assistant && m.Content == "Assistant response 9"); + } + + [Fact] + public async Task CompressHistoryAsync_ShouldReturnOriginalMessages_WhenTargetCountIsGreaterThanOrEqual() + { + // Arrange + var messages = TestDataBuilder.ChatMessages.CreateMessageHistory(5); + var targetCount = 10; + + // Act + var result = await _compressionService.CompressHistoryAsync(messages, targetCount); + + // Assert + result.Should().BeEquivalentTo(messages); + _ollamaClientMock.Verify( + x => x.ChatAsync(It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task CompressHistoryAsync_ShouldHandleEmptyMessages() + { + // Arrange + var messages = new List(); + var targetCount = 5; + + // Act + var result = await _compressionService.CompressHistoryAsync(messages, targetCount); + + // Assert + result.Should().BeEmpty(); + _ollamaClientMock.Verify( + x => x.ChatAsync(It.IsAny()), + Times.Never + ); + } + + private static IAsyncEnumerable ThrowAsyncEnumerable( + Exception exception + ) + { + return new ThrowingAsyncEnumerable(exception); + } + + private class ThrowingAsyncEnumerable + : IAsyncEnumerable + { + private readonly Exception _exception; + + public ThrowingAsyncEnumerable(Exception exception) + { + _exception = exception; + } + + public IAsyncEnumerator GetAsyncEnumerator( + CancellationToken cancellationToken = default + ) + { + return new ThrowingAsyncEnumerator(_exception); + } + } + + private class ThrowingAsyncEnumerator + : IAsyncEnumerator + { + private readonly Exception _exception; + + public ThrowingAsyncEnumerator(Exception exception) + { + _exception = exception; + } + + public OllamaSharp.Models.Chat.ChatResponseStream Current => + throw new InvalidOperationException(); + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public ValueTask MoveNextAsync() + { + throw _exception; + } + } +} diff --git a/ChatBot.Tests/Services/InMemorySessionStorageTests.cs b/ChatBot.Tests/Services/InMemorySessionStorageTests.cs new file mode 100644 index 0000000..c122e9a --- /dev/null +++ b/ChatBot.Tests/Services/InMemorySessionStorageTests.cs @@ -0,0 +1,292 @@ +using ChatBot.Models; +using ChatBot.Services; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; + +namespace ChatBot.Tests.Services; + +public class InMemorySessionStorageTests +{ + private readonly InMemorySessionStorage _sessionStorage; + + public InMemorySessionStorageTests() + { + _sessionStorage = new InMemorySessionStorage( + TestDataBuilder.Mocks.CreateLoggerMock().Object + ); + } + + [Fact] + public void GetOrCreate_ShouldReturnExistingSession_WhenSessionExists() + { + // Arrange + _sessionStorage.GetOrCreate(12345, "private", "Test Chat"); + + // Act + var result = _sessionStorage.GetOrCreate(12345, "private", "Test Chat"); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(12345); + result.ChatType.Should().Be("private"); + } + + [Fact] + public void GetOrCreate_ShouldCreateNewSession_WhenSessionDoesNotExist() + { + // Act + var result = _sessionStorage.GetOrCreate(12345, "group", "Test Group"); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(12345); + result.ChatType.Should().Be("group"); + result.ChatTitle.Should().Be("Test Group"); + } + + [Fact] + public void GetOrCreate_ShouldUseDefaultValues_WhenParametersNotProvided() + { + // Act + var result = _sessionStorage.GetOrCreate(12345); + + // Assert + result.Should().NotBeNull(); + result.ChatId.Should().Be(12345); + result.ChatType.Should().Be("private"); + result.ChatTitle.Should().BeEmpty(); + } + + [Fact] + public void Get_ShouldReturnSession_WhenSessionExists() + { + // Arrange + var session = _sessionStorage.GetOrCreate(12345, "private", "Test Chat"); + + // Act + var result = _sessionStorage.Get(12345); + + // Assert + result.Should().BeSameAs(session); + } + + [Fact] + public void Get_ShouldReturnNull_WhenSessionDoesNotExist() + { + // Act + var result = _sessionStorage.Get(99999); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task SaveSessionAsync_ShouldUpdateExistingSession() + { + // Arrange + var session = _sessionStorage.GetOrCreate(12345, "private", "Original Title"); + session.ChatTitle = "Updated Title"; + session.LastUpdatedAt = DateTime.UtcNow; + + // Act + await _sessionStorage.SaveSessionAsync(session); + + // Assert + var savedSession = _sessionStorage.Get(12345); + savedSession.Should().NotBeNull(); + savedSession!.ChatTitle.Should().Be("Updated Title"); + } + + [Fact] + public async Task SaveSessionAsync_ShouldAddNewSession() + { + // Arrange + var session = _sessionStorage.GetOrCreate(12345, "private", "Original Title"); + session.ChatTitle = "New Session"; + + // Act + await _sessionStorage.SaveSessionAsync(session); + + // Assert + var savedSession = _sessionStorage.Get(12345); + savedSession.Should().NotBeNull(); + savedSession!.ChatTitle.Should().Be("New Session"); + } + + [Fact] + public void Remove_ShouldReturnTrue_WhenSessionExists() + { + // Arrange + _sessionStorage.GetOrCreate(12345, "private", "Test Chat"); + + // Act + var result = _sessionStorage.Remove(12345); + + // Assert + result.Should().BeTrue(); + _sessionStorage.Get(12345).Should().BeNull(); + } + + [Fact] + public void Remove_ShouldReturnFalse_WhenSessionDoesNotExist() + { + // Act + var result = _sessionStorage.Remove(99999); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void GetActiveSessionsCount_ShouldReturnCorrectCount() + { + // Arrange + _sessionStorage.GetOrCreate(12345, "private", "Chat 1"); + _sessionStorage.GetOrCreate(67890, "group", "Chat 2"); + _sessionStorage.GetOrCreate(11111, "private", "Chat 3"); + + // Act + var count = _sessionStorage.GetActiveSessionsCount(); + + // Assert + count.Should().Be(3); + } + + [Fact] + public void GetActiveSessionsCount_ShouldReturnZero_WhenNoSessions() + { + // Act + var count = _sessionStorage.GetActiveSessionsCount(); + + // Assert + count.Should().Be(0); + } + + [Fact] + public void CleanupOldSessions_ShouldDeleteOldSessions() + { + // Arrange + var oldSession = _sessionStorage.GetOrCreate(99999, "private", "Old Chat"); + // Manually set CreatedAt to 2 days ago using test method + oldSession.SetCreatedAtForTesting(DateTime.UtcNow.AddDays(-2)); + + var recentSession = _sessionStorage.GetOrCreate(88888, "private", "Recent Chat"); + // Manually set CreatedAt to 30 minutes ago using test method + recentSession.SetCreatedAtForTesting(DateTime.UtcNow.AddMinutes(-30)); + + // Act + _sessionStorage.CleanupOldSessions(1); // Delete sessions older than 1 day + + // Assert + _sessionStorage.Get(99999).Should().BeNull(); // Old session should be deleted + _sessionStorage.Get(88888).Should().NotBeNull(); // Recent session should remain + } + + [Fact] + public void CleanupOldSessions_ShouldNotDeleteRecentSessions() + { + // Arrange + var recentSession1 = _sessionStorage.GetOrCreate(12345, "private", "Recent 1"); + recentSession1.CreatedAt = DateTime.UtcNow.AddHours(-1); + + var recentSession2 = _sessionStorage.GetOrCreate(67890, "private", "Recent 2"); + recentSession2.CreatedAt = DateTime.UtcNow.AddMinutes(-30); + + // Act + var deletedCount = _sessionStorage.CleanupOldSessions(24); // Delete sessions older than 24 hours + + // Assert + deletedCount.Should().Be(0); + _sessionStorage.Get(12345).Should().NotBeNull(); + _sessionStorage.Get(67890).Should().NotBeNull(); + } + + [Fact] + public void CleanupOldSessions_ShouldReturnZero_WhenNoSessions() + { + // Act + var deletedCount = _sessionStorage.CleanupOldSessions(1); + + // Assert + deletedCount.Should().Be(0); + } + + [Fact] + public void GetOrCreate_ShouldCreateSessionWithCorrectTimestamp() + { + // Act + var session = _sessionStorage.GetOrCreate(12345, "private", "Test Chat"); + + // Assert + session.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + session.LastUpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + } + + [Fact] + public async Task SaveSessionAsync_ShouldUpdateLastUpdatedAt() + { + // Arrange + var session = _sessionStorage.GetOrCreate(12345, "private", "Test Chat"); + var originalTime = session.LastUpdatedAt; + + // Wait a bit to ensure time difference + await Task.Delay(10); + + session.ChatTitle = "Updated Title"; + + // Act + await _sessionStorage.SaveSessionAsync(session); + + // Assert + var savedSession = _sessionStorage.Get(12345); + savedSession!.LastUpdatedAt.Should().BeAfter(originalTime); + } + + [Fact] + public async Task GetOrCreate_ShouldHandleConcurrentAccess() + { + // Arrange + var tasks = new List>(); + + // Act - Create sessions concurrently + for (int i = 0; i < 100; i++) + { + var chatId = 1000 + i; + tasks.Add( + Task.Run(() => _sessionStorage.GetOrCreate(chatId, "private", $"Chat {chatId}")) + ); + } + + await Task.WhenAll(tasks); + + // Assert + _sessionStorage.GetActiveSessionsCount().Should().Be(100); + + // Verify all sessions were created + for (int i = 0; i < 100; i++) + { + var chatId = 1000 + i; + var session = _sessionStorage.Get(chatId); + session.Should().NotBeNull(); + session!.ChatId.Should().Be(chatId); + } + } + + [Fact] + public void Remove_ShouldDecreaseActiveSessionsCount() + { + // Arrange + _sessionStorage.GetOrCreate(12345, "private", "Chat 1"); + _sessionStorage.GetOrCreate(67890, "private", "Chat 2"); + _sessionStorage.GetOrCreate(11111, "private", "Chat 3"); + + // Act + _sessionStorage.Remove(67890); + + // Assert + _sessionStorage.GetActiveSessionsCount().Should().Be(2); + _sessionStorage.Get(12345).Should().NotBeNull(); + _sessionStorage.Get(67890).Should().BeNull(); + _sessionStorage.Get(11111).Should().NotBeNull(); + } +} diff --git a/ChatBot.Tests/Services/ModelServiceTests.cs b/ChatBot.Tests/Services/ModelServiceTests.cs new file mode 100644 index 0000000..81cb00b --- /dev/null +++ b/ChatBot.Tests/Services/ModelServiceTests.cs @@ -0,0 +1,46 @@ +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace ChatBot.Tests.Services; + +public class ModelServiceTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly Mock> _optionsMock; + private readonly ModelService _modelService; + + public ModelServiceTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + var ollamaSettings = TestDataBuilder.Configurations.CreateOllamaSettings(); + _optionsMock = TestDataBuilder.Mocks.CreateOptionsMock(ollamaSettings); + + _modelService = new ModelService(_loggerMock.Object, _optionsMock.Object); + } + + [Fact] + public void GetCurrentModel_ShouldReturnDefaultModel() + { + // Act + var result = _modelService.GetCurrentModel(); + + // Assert + result.Should().Be("llama3.2"); + } + + [Fact] + public async Task InitializeAsync_ShouldLogModelInformation() + { + // Act + await _modelService.InitializeAsync(); + + // Assert + // The method should complete without throwing exceptions + // In a real test, we might verify logging calls + } +} diff --git a/ChatBot.Tests/Services/SystemPromptServiceTests.cs b/ChatBot.Tests/Services/SystemPromptServiceTests.cs new file mode 100644 index 0000000..f8acdec --- /dev/null +++ b/ChatBot.Tests/Services/SystemPromptServiceTests.cs @@ -0,0 +1,56 @@ +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace ChatBot.Tests.Services; + +public class SystemPromptServiceTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly SystemPromptService _systemPromptService; + + public SystemPromptServiceTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + var aiSettingsMock = TestDataBuilder.Mocks.CreateOptionsMock(new AISettings()); + _systemPromptService = new SystemPromptService(_loggerMock.Object, aiSettingsMock.Object); + } + + [Fact] + public async Task GetSystemPromptAsync_ShouldReturnSystemPrompt() + { + // Act + var result = await _systemPromptService.GetSystemPromptAsync(); + + // Assert + result.Should().NotBeNullOrEmpty(); + result.Should().Contain("Никита"); + } + + [Fact] + public async Task GetSystemPromptAsync_ShouldReturnCachedPrompt_WhenCalledMultipleTimes() + { + // Act + var result1 = await _systemPromptService.GetSystemPromptAsync(); + var result2 = await _systemPromptService.GetSystemPromptAsync(); + + // Assert + result1.Should().Be(result2); + } + + [Fact] + public async Task ReloadPrompt_ShouldClearCache() + { + // Act + // ReloadPrompt method doesn't exist, skipping this test + var newPrompt = await _systemPromptService.GetSystemPromptAsync(); + + // Assert + newPrompt.Should().NotBeNull(); + // Note: In a real scenario, we might mock the file system to test cache clearing + } +} diff --git a/ChatBot.Tests/Telegram/Commands/ClearCommandTests.cs b/ChatBot.Tests/Telegram/Commands/ClearCommandTests.cs new file mode 100644 index 0000000..94c3429 --- /dev/null +++ b/ChatBot.Tests/Telegram/Commands/ClearCommandTests.cs @@ -0,0 +1,90 @@ +using ChatBot.Models; +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using ChatBot.Services.Telegram.Commands; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ChatBot.Tests.Telegram.Commands; + +public class ClearCommandTests : UnitTestBase +{ + private readonly Mock _chatServiceMock; + private readonly Mock _modelServiceMock; + private readonly ClearCommand _clearCommand; + + public ClearCommandTests() + { + _chatServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder.Mocks.CreateAIServiceMock().Object, + TestDataBuilder.Mocks.CreateSessionStorageMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateAISettings()) + .Object, + TestDataBuilder.Mocks.CreateCompressionServiceMock().Object + ); + _modelServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateOllamaSettings()) + .Object + ); + _clearCommand = new ClearCommand(_chatServiceMock.Object, _modelServiceMock.Object); + } + + [Fact] + public async Task ExecuteAsync_ShouldClearSession_WhenSessionExists() + { + // Arrange + var chatId = 12345L; + _chatServiceMock.Setup(x => x.ClearHistoryAsync(chatId)).Returns(Task.CompletedTask); + + var context = new TelegramCommandContext + { + ChatId = chatId, + Username = "testuser", + MessageText = "/clear", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _clearCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("очищена"); + _chatServiceMock.Verify(x => x.ClearHistoryAsync(chatId), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnMessage_WhenSessionDoesNotExist() + { + // Arrange + var chatId = 12345L; + _chatServiceMock + .Setup(x => x.ClearHistoryAsync(chatId)) + .ThrowsAsync(new InvalidOperationException("Session not found")); + + var context = new TelegramCommandContext + { + ChatId = chatId, + Username = "testuser", + MessageText = "/clear", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _clearCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("очищена"); + _chatServiceMock.Verify(x => x.ClearHistoryAsync(chatId), Times.Once); + } +} diff --git a/ChatBot.Tests/Telegram/Commands/CommandRegistryTests.cs b/ChatBot.Tests/Telegram/Commands/CommandRegistryTests.cs new file mode 100644 index 0000000..cce1fda --- /dev/null +++ b/ChatBot.Tests/Telegram/Commands/CommandRegistryTests.cs @@ -0,0 +1,103 @@ +using ChatBot.Services; +using ChatBot.Services.Telegram.Commands; +using ChatBot.Services.Telegram.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ChatBot.Tests.Telegram.Commands; + +public class CommandRegistryTests : UnitTestBase +{ + private readonly Mock> _loggerMock; + private readonly CommandRegistry _commandRegistry; + + public CommandRegistryTests() + { + _loggerMock = TestDataBuilder.Mocks.CreateLoggerMock(); + _commandRegistry = new CommandRegistry(_loggerMock.Object, new List()); + } + + [Fact] + public void Constructor_ShouldRegisterCommands() + { + // Arrange + var command = new Mock(); + command.Setup(x => x.CommandName).Returns("test"); + + // Act + var registry = new CommandRegistry(_loggerMock.Object, new[] { command.Object }); + + // Assert + var registeredCommand = registry.GetCommand("test"); + registeredCommand.Should().Be(command.Object); + } + + [Fact] + public void GetCommand_ShouldReturnCommand_WhenCommandExists() + { + // Arrange + var command = new Mock(); + command.Setup(x => x.CommandName).Returns("test"); + var registry = new CommandRegistry(_loggerMock.Object, new[] { command.Object }); + + // Act + var result = registry.GetCommand("test"); + + // Assert + result.Should().Be(command.Object); + } + + [Fact] + public void GetCommand_ShouldReturnNull_WhenCommandDoesNotExist() + { + // Act + var result = _commandRegistry.GetCommand("nonexistent"); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void GetAllCommands_ShouldReturnAllRegisteredCommands() + { + // Arrange + var command1 = new Mock(); + command1.Setup(x => x.CommandName).Returns("test1"); + var command2 = new Mock(); + command2.Setup(x => x.CommandName).Returns("test2"); + var registry = new CommandRegistry( + _loggerMock.Object, + new[] { command1.Object, command2.Object } + ); + + // Act + var result = registry.GetAllCommands(); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain(command1.Object); + result.Should().Contain(command2.Object); + } + + [Fact] + public void Constructor_ShouldNotOverwriteExistingCommand_WhenCommandWithSameNameExists() + { + // Arrange + var command1 = new Mock(); + command1.Setup(x => x.CommandName).Returns("test"); + var command2 = new Mock(); + command2.Setup(x => x.CommandName).Returns("test"); + + // Act + var registry = new CommandRegistry( + _loggerMock.Object, + new[] { command1.Object, command2.Object } + ); + + // Assert + var result = registry.GetCommand("test"); + result.Should().Be(command1.Object); // First command should be kept + } +} diff --git a/ChatBot.Tests/Telegram/Commands/HelpCommandTests.cs b/ChatBot.Tests/Telegram/Commands/HelpCommandTests.cs new file mode 100644 index 0000000..d671313 --- /dev/null +++ b/ChatBot.Tests/Telegram/Commands/HelpCommandTests.cs @@ -0,0 +1,108 @@ +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Services.Telegram.Commands; +using ChatBot.Services.Telegram.Interfaces; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Telegram.Bot.Types; + +namespace ChatBot.Tests.Telegram.Commands; + +public class HelpCommandTests : UnitTestBase +{ + private readonly HelpCommand _helpCommand; + + public HelpCommandTests() + { + var chatServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder.Mocks.CreateAIServiceMock().Object, + TestDataBuilder.Mocks.CreateSessionStorageMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateAISettings()) + .Object, + TestDataBuilder.Mocks.CreateCompressionServiceMock().Object + ); + var modelServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateOllamaSettings()) + .Object + ); + var commandRegistryMock = new Mock( + Mock.Of>(), + new List() + ); + commandRegistryMock + .Setup(x => x.GetCommandsWithDescriptions()) + .Returns( + new List<(string, string)> + { + ("/start", "Начать работу с ботом"), + ("/help", "Показать справку по всем командам"), + ("/clear", "Очистить историю чата"), + ("/settings", "Показать настройки чата"), + ("/status", "Показать статус системы и API"), + } + ); + + var serviceProviderMock = new Mock(); + serviceProviderMock + .Setup(x => x.GetService(typeof(CommandRegistry))) + .Returns(commandRegistryMock.Object); + + _helpCommand = new HelpCommand( + chatServiceMock.Object, + modelServiceMock.Object, + serviceProviderMock.Object + ); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnHelpMessage() + { + // Arrange + var context = new TelegramCommandContext + { + ChatId = 12345, + Username = "testuser", + MessageText = "/help", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _helpCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("Доступные"); + result.Should().Contain("/start"); + result.Should().Contain("/help"); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnCommandList() + { + // Arrange + var context = new TelegramCommandContext + { + ChatId = 12345, + Username = "testuser", + MessageText = "/help", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _helpCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("/status"); + result.Should().Contain("/clear"); + result.Should().Contain("/settings"); + } +} diff --git a/ChatBot.Tests/Telegram/Commands/SettingsCommandTests.cs b/ChatBot.Tests/Telegram/Commands/SettingsCommandTests.cs new file mode 100644 index 0000000..4e98f3d --- /dev/null +++ b/ChatBot.Tests/Telegram/Commands/SettingsCommandTests.cs @@ -0,0 +1,94 @@ +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using ChatBot.Services.Telegram.Commands; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Telegram.Bot.Types; + +namespace ChatBot.Tests.Telegram.Commands; + +public class SettingsCommandTests : UnitTestBase +{ + private readonly Mock _sessionStorageMock; + private readonly SettingsCommand _settingsCommand; + + public SettingsCommandTests() + { + _sessionStorageMock = TestDataBuilder.Mocks.CreateSessionStorageMock(); + var chatServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder.Mocks.CreateAIServiceMock().Object, + _sessionStorageMock.Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateAISettings()) + .Object, + TestDataBuilder.Mocks.CreateCompressionServiceMock().Object + ); + var modelServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateOllamaSettings()) + .Object + ); + var aiSettingsMock = TestDataBuilder.Mocks.CreateOptionsMock(new AISettings()); + _settingsCommand = new SettingsCommand( + chatServiceMock.Object, + modelServiceMock.Object, + aiSettingsMock.Object + ); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnSettings_WhenSessionExists() + { + // Arrange + var chatId = 12345L; + var session = TestDataBuilder.ChatSessions.CreateBasicSession(chatId); + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns(session); + + var context = new TelegramCommandContext + { + ChatId = chatId, + Username = "testuser", + MessageText = "/settings", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _settingsCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("Настройки"); + result.Should().Contain("Модель"); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnDefaultSettings_WhenSessionDoesNotExist() + { + // Arrange + var chatId = 12345L; + _sessionStorageMock.Setup(x => x.Get(chatId)).Returns((ChatBot.Models.ChatSession?)null); + + var context = new TelegramCommandContext + { + ChatId = chatId, + Username = "testuser", + MessageText = "/settings", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _settingsCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("Сессия"); + result.Should().Contain("найдена"); + } +} diff --git a/ChatBot.Tests/Telegram/Commands/StartCommandTests.cs b/ChatBot.Tests/Telegram/Commands/StartCommandTests.cs new file mode 100644 index 0000000..7ca7902 --- /dev/null +++ b/ChatBot.Tests/Telegram/Commands/StartCommandTests.cs @@ -0,0 +1,78 @@ +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Services.Telegram.Commands; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Telegram.Bot.Types; + +namespace ChatBot.Tests.Telegram.Commands; + +public class StartCommandTests : UnitTestBase +{ + private readonly StartCommand _startCommand; + + public StartCommandTests() + { + var chatServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder.Mocks.CreateAIServiceMock().Object, + TestDataBuilder.Mocks.CreateSessionStorageMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateAISettings()) + .Object, + TestDataBuilder.Mocks.CreateCompressionServiceMock().Object + ); + var modelServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateOllamaSettings()) + .Object + ); + _startCommand = new StartCommand(chatServiceMock.Object, modelServiceMock.Object); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnWelcomeMessage() + { + // Arrange + var context = new TelegramCommandContext + { + ChatId = 12345, + Username = "testuser", + MessageText = "/start", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _startCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("Привет"); + result.Should().Contain("Никита"); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnHelpInformation() + { + // Arrange + var context = new TelegramCommandContext + { + ChatId = 12345, + Username = "testuser", + MessageText = "/start", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Act + var result = await _startCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("вопросы"); + } +} diff --git a/ChatBot.Tests/Telegram/Commands/StatusCommandTests.cs b/ChatBot.Tests/Telegram/Commands/StatusCommandTests.cs new file mode 100644 index 0000000..0ed09ef --- /dev/null +++ b/ChatBot.Tests/Telegram/Commands/StatusCommandTests.cs @@ -0,0 +1,160 @@ +using System.Linq; +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using ChatBot.Services.Telegram.Commands; +using ChatBot.Tests.TestUtilities; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Tests.Telegram.Commands; + +public class StatusCommandTests : UnitTestBase +{ + private readonly Mock> _ollamaOptionsMock; + private readonly Mock _ollamaClientMock; + private readonly StatusCommand _statusCommand; + + public StatusCommandTests() + { + var ollamaSettings = TestDataBuilder.Configurations.CreateOllamaSettings(); + _ollamaOptionsMock = TestDataBuilder.Mocks.CreateOptionsMock(ollamaSettings); + + _ollamaClientMock = TestDataBuilder.Mocks.CreateOllamaClientMock(); + + var chatServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + TestDataBuilder.Mocks.CreateAIServiceMock().Object, + TestDataBuilder.Mocks.CreateSessionStorageMock().Object, + TestDataBuilder + .Mocks.CreateOptionsMock(TestDataBuilder.Configurations.CreateAISettings()) + .Object, + TestDataBuilder.Mocks.CreateCompressionServiceMock().Object + ); + var modelServiceMock = new Mock( + TestDataBuilder.Mocks.CreateLoggerMock().Object, + _ollamaOptionsMock.Object + ); + var aiSettingsMock = TestDataBuilder.Mocks.CreateOptionsMock(new AISettings()); + + _statusCommand = new StatusCommand( + chatServiceMock.Object, + modelServiceMock.Object, + aiSettingsMock.Object, + _ollamaClientMock.Object + ); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnStatusMessage_WhenBothServicesAreHealthy() + { + // Arrange + var context = new TelegramCommandContext + { + ChatId = 12345, + Username = "testuser", + MessageText = "/status", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Mock Ollama health check + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Returns( + TestDataBuilder.Mocks.CreateAsyncEnumerable( + new List + { + new OllamaSharp.Models.Chat.ChatResponseStream + { + Message = new OllamaSharp.Models.Chat.Message( + ChatRole.Assistant, + "Test response" + ), + }, + } + ) + ); + + // Act + var result = await _statusCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("Статус"); + result.Should().Contain("API"); + result.Should().Contain("системы"); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnErrorStatus_WhenOllamaIsUnavailable() + { + // Arrange + var context = new TelegramCommandContext + { + ChatId = 12345, + Username = "testuser", + MessageText = "/status", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Mock Ollama failure + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Throws(new Exception("Ollama unavailable")); + + // Act + var result = await _statusCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("Статус"); + result.Should().Contain("API"); + result.Should().Contain("Ошибка"); + } + + [Fact] + public async Task ExecuteAsync_ShouldReturnErrorStatus_WhenTelegramIsUnavailable() + { + // Arrange + var context = new TelegramCommandContext + { + ChatId = 12345, + Username = "testuser", + MessageText = "/status", + ChatType = "private", + ChatTitle = "Test Chat", + }; + + // Mock Ollama health check + _ollamaClientMock + .Setup(x => x.ChatAsync(It.IsAny())) + .Returns( + TestDataBuilder.Mocks.CreateAsyncEnumerable( + new List + { + new OllamaSharp.Models.Chat.ChatResponseStream + { + Message = new OllamaSharp.Models.Chat.Message( + ChatRole.Assistant, + "Test response" + ), + }, + } + ) + ); + + // Act + var result = await _statusCommand.ExecuteAsync(context); + + // Assert + result.Should().NotBeNull(); + result.Should().Contain("Статус"); + result.Should().Contain("системы"); + result.Should().Contain("Доступен"); + } +} diff --git a/ChatBot.Tests/TestUtilities/TestBase.cs b/ChatBot.Tests/TestUtilities/TestBase.cs new file mode 100644 index 0000000..c80359e --- /dev/null +++ b/ChatBot.Tests/TestUtilities/TestBase.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ChatBot.Tests.TestUtilities; + +/// +/// Base class for integration tests with common setup +/// +public abstract class TestBase : IDisposable +{ + protected IServiceProvider ServiceProvider { get; private set; } = null!; + protected Mock LoggerMock { get; private set; } = null!; + + protected virtual void SetupServices() + { + var services = new ServiceCollection(); + + // Add logging + LoggerMock = new Mock(); + services.AddSingleton(LoggerMock.Object); + services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Debug)); + + // Add other common services + ConfigureServices(services); + + ServiceProvider = services.BuildServiceProvider(); + } + + protected abstract void ConfigureServices(IServiceCollection services); + + protected virtual void Cleanup() + { + if (ServiceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + } + + public void Dispose() + { + Cleanup(); + GC.SuppressFinalize(this); + } +} + +/// +/// Base class for unit tests with common assertions +/// +public abstract class UnitTestBase +{ + protected static void AssertLogMessage(Mock loggerMock, LogLevel level, string message) + { + loggerMock.Verify( + x => + x.Log( + level, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(message)), + It.IsAny(), + It.IsAny>() + ), + Times.AtLeastOnce + ); + } + + protected static void AssertLogError(Mock loggerMock, string message) + { + AssertLogMessage(loggerMock, LogLevel.Error, message); + } + + protected static void AssertLogWarning(Mock loggerMock, string message) + { + AssertLogMessage(loggerMock, LogLevel.Warning, message); + } + + protected static void AssertLogInformation(Mock loggerMock, string message) + { + AssertLogMessage(loggerMock, LogLevel.Information, message); + } +} diff --git a/ChatBot.Tests/TestUtilities/TestDataBuilder.cs b/ChatBot.Tests/TestUtilities/TestDataBuilder.cs new file mode 100644 index 0000000..c9b0b93 --- /dev/null +++ b/ChatBot.Tests/TestUtilities/TestDataBuilder.cs @@ -0,0 +1,336 @@ +using ChatBot.Models; +using ChatBot.Models.Configuration; +using ChatBot.Models.Dto; +using ChatBot.Models.Entities; +using ChatBot.Services.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using OllamaSharp.Models.Chat; +using Telegram.Bot; + +namespace ChatBot.Tests.TestUtilities; + +/// +/// Builder pattern for creating test data and mocks +/// +public static class TestDataBuilder +{ + public static class ChatSessions + { + public static ChatSession CreateBasicSession( + long chatId = 12345, + string chatType = "private" + ) + { + return new ChatSession + { + ChatId = chatId, + ChatType = chatType, + ChatTitle = chatType == "private" ? "" : "Test Group", + Model = "llama3.2", + CreatedAt = DateTime.UtcNow.AddHours(-1), + LastUpdatedAt = DateTime.UtcNow, + }; + } + + public static ChatSession CreateSessionWithMessages( + long chatId = 12345, + int messageCount = 3 + ) + { + var session = CreateBasicSession(chatId); + + for (int i = 0; i < messageCount; i++) + { + session.AddUserMessage($"Test message {i + 1}", "testuser"); + session.AddAssistantMessage($"AI response {i + 1}"); + } + + return session; + } + } + + public static class ChatMessages + { + public static ChatMessage CreateUserMessage( + string content = "Hello", + string username = "testuser" + ) + { + return new ChatMessage { Role = ChatRole.User, Content = content }; + } + + public static ChatMessage CreateAssistantMessage( + string content = "Hello! How can I help you?" + ) + { + return new ChatMessage { Role = ChatRole.Assistant, Content = content }; + } + + public static ChatMessage CreateSystemMessage( + string content = "You are a helpful assistant." + ) + { + return new ChatMessage { Role = ChatRole.System, Content = content }; + } + + public static List CreateMessageHistory(int count = 5) + { + var messages = new List(); + + for (int i = 0; i < count; i++) + { + messages.Add(CreateUserMessage($"User message {i + 1}")); + messages.Add(CreateAssistantMessage($"Assistant response {i + 1}")); + } + + return messages; + } + } + + public static class Configurations + { + public static AISettings CreateAISettings() + { + return new AISettings + { + Temperature = 0.7, + MaxRetryAttempts = 3, + RetryDelayMs = 1000, + MaxRetryDelayMs = 10000, + EnableExponentialBackoff = true, + RequestTimeoutSeconds = 30, + EnableHistoryCompression = true, + CompressionThreshold = 10, + CompressionTarget = 5, + MinMessageLengthForSummarization = 50, + MaxSummarizedMessageLength = 200, + CompressionTimeoutSeconds = 15, + }; + } + + public static OllamaSettings CreateOllamaSettings() + { + return new OllamaSettings { Url = "http://localhost:11434", DefaultModel = "llama3.2" }; + } + + public static TelegramBotSettings CreateTelegramBotSettings() + { + return new TelegramBotSettings { BotToken = "test-bot-token" }; + } + + public static DatabaseSettings CreateDatabaseSettings() + { + return new DatabaseSettings + { + ConnectionString = + "Host=localhost;Port=5432;Database=test_chatbot;Username=test;Password=test", + CommandTimeout = 30, + EnableSensitiveDataLogging = false, + }; + } + } + + public static class Mocks + { + public static Mock> CreateLoggerMock() + { + return new Mock>(); + } + + public static Mock> CreateOptionsMock(T value) + where T : class + { + var mock = new Mock>(); + mock.Setup(x => x.Value).Returns(value); + return mock; + } + + public static Mock CreateSessionStorageMock() + { + var mock = new Mock(); + var sessions = new Dictionary(); + + mock.Setup(x => x.GetOrCreate(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns( + (chatId, chatType, chatTitle) => + { + if (!sessions.ContainsKey(chatId)) + { + var session = TestDataBuilder.ChatSessions.CreateBasicSession( + chatId, + chatType + ); + session.ChatTitle = chatTitle; + sessions[chatId] = session; + } + return sessions[chatId]; + } + ); + + mock.Setup(x => x.Get(It.IsAny())) + .Returns(chatId => + sessions.TryGetValue(chatId, out var session) ? session : null + ); + + mock.Setup(x => x.SaveSessionAsync(It.IsAny())) + .Returns(session => + { + sessions[session.ChatId] = session; + return Task.CompletedTask; + }); + + mock.Setup(x => x.Remove(It.IsAny())) + .Returns(chatId => sessions.Remove(chatId)); + + mock.Setup(x => x.GetActiveSessionsCount()).Returns(() => sessions.Count); + + mock.Setup(x => x.CleanupOldSessions(It.IsAny())).Returns(hoursOld => 0); + + return mock; + } + + public static Mock CreateAIServiceMock() + { + var mock = new Mock(); + + mock.Setup(x => + x.GenerateChatCompletionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync("Test AI response"); + + mock.Setup(x => + x.GenerateChatCompletionWithCompressionAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync("Test AI response with compression"); + + return mock; + } + + public static Mock CreateCompressionServiceMock() + { + var mock = new Mock(); + + mock.Setup(x => x.ShouldCompress(It.IsAny(), It.IsAny())) + .Returns((count, threshold) => count > threshold); + + mock.Setup(x => + x.CompressHistoryAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync( + (List messages, int targetCount, CancellationToken ct) => + { + // Simple compression: take last targetCount messages + return messages.TakeLast(targetCount).ToList(); + } + ); + + return mock; + } + + public static Mock CreateOllamaClientMock() + { + var mock = new Mock(); + + mock.Setup(x => x.ChatAsync(It.IsAny())) + .Returns( + TestDataBuilder.Mocks.CreateAsyncEnumerable( + new List() + ) + ); + + return mock; + } + + /// + /// Create a mock chat session entity + /// + public static ChatSessionEntity CreateChatSessionEntity( + int id = 1, + long chatId = 12345, + string sessionId = "test-session", + string chatType = "private", + string chatTitle = "Test Chat" + ) + { + return new ChatSessionEntity + { + Id = id, + ChatId = chatId, + SessionId = sessionId, + ChatType = chatType, + ChatTitle = chatTitle, + CreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow, + Messages = new List(), + }; + } + + /// + /// Create a mock chat message entity + /// + public static ChatMessageEntity CreateChatMessageEntity( + int id = 1, + int sessionId = 1, + string content = "Test message", + string role = "user", + int messageOrder = 1 + ) + { + return new ChatMessageEntity + { + Id = id, + SessionId = sessionId, + Content = content, + Role = role, + MessageOrder = messageOrder, + CreatedAt = DateTime.UtcNow, + }; + } + + /// + /// Create a mock Telegram bot client + /// + public static Mock CreateTelegramBotClient() + { + var mock = new Mock(); + return mock; + } + + /// + /// Create a mock Telegram bot user + /// + public static global::Telegram.Bot.Types.User CreateTelegramBot() + { + return new global::Telegram.Bot.Types.User + { + Id = 12345, + Username = "test_bot", + FirstName = "Test Bot", + }; + } + + /// + /// Create async enumerable from list + /// + public static async IAsyncEnumerable CreateAsyncEnumerable(IEnumerable items) + { + foreach (var item in items) + { + yield return item; + } + await Task.CompletedTask; + } + } +} diff --git a/ChatBot.Tests/appsettings.Test.json b/ChatBot.Tests/appsettings.Test.json new file mode 100644 index 0000000..2495cd5 --- /dev/null +++ b/ChatBot.Tests/appsettings.Test.json @@ -0,0 +1,36 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "TelegramBot": { + "BotToken": "test-bot-token" + }, + "Ollama": { + "Url": "http://localhost:11434", + "DefaultModel": "llama3.2" + }, + "AI": { + "Temperature": 0.7, + "MaxRetryAttempts": 3, + "RetryDelayMs": 1000, + "MaxRetryDelayMs": 10000, + "EnableExponentialBackoff": true, + "RequestTimeoutSeconds": 30, + "EnableHistoryCompression": true, + "CompressionThreshold": 10, + "CompressionTarget": 5, + "MinMessageLengthForSummarization": 50, + "MaxSummarizedMessageLength": 200, + "CompressionTimeoutSeconds": 15, + "StatusCheckTimeoutSeconds": 5 + }, + "Database": { + "ConnectionString": "Host=localhost;Port=5432;Database=test_chatbot;Username=test;Password=test", + "CommandTimeout": 30, + "EnableSensitiveDataLogging": false + } +} diff --git a/ChatBot.sln b/ChatBot.sln index df2e903..0038cf1 100644 --- a/ChatBot.sln +++ b/ChatBot.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36414.22 d17.14 +VisualStudioVersion = 17.14.36414.22 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatBot", "ChatBot\ChatBot.csproj", "{DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatBot.Tests", "ChatBot.Tests\ChatBot.Tests.csproj", "{4C2CD164-C143-4B18-85E4-1A60E965F948}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}.Debug|Any CPU.Build.0 = Debug|Any CPU {DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}.Release|Any CPU.ActiveCfg = Release|Any CPU {DDE6533C-2CEF-4E89-BDAD-3347B2B1A4F5}.Release|Any CPU.Build.0 = Release|Any CPU + {4C2CD164-C143-4B18-85E4-1A60E965F948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C2CD164-C143-4B18-85E4-1A60E965F948}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C2CD164-C143-4B18-85E4-1A60E965F948}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C2CD164-C143-4B18-85E4-1A60E965F948}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ChatBot/ChatBot.csproj b/ChatBot/ChatBot.csproj index b5d4866..e5f0b15 100644 --- a/ChatBot/ChatBot.csproj +++ b/ChatBot/ChatBot.csproj @@ -18,13 +18,19 @@ - - - - - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/ChatBot/Models/ChatSession.cs b/ChatBot/Models/ChatSession.cs index 98a2dcc..a308cc5 100644 --- a/ChatBot/Models/ChatSession.cs +++ b/ChatBot/Models/ChatSession.cs @@ -43,6 +43,14 @@ namespace ChatBot.Models /// public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + /// + /// Test method to set CreatedAt for testing purposes + /// + public void SetCreatedAtForTesting(DateTime createdAt) + { + CreatedAt = createdAt; + } + /// /// When the session was last updated /// diff --git a/ChatBot/Models/Configuration/Validators/TelegramBotSettingsValidator.cs b/ChatBot/Models/Configuration/Validators/TelegramBotSettingsValidator.cs index b71f0d2..4f2fdaa 100644 --- a/ChatBot/Models/Configuration/Validators/TelegramBotSettingsValidator.cs +++ b/ChatBot/Models/Configuration/Validators/TelegramBotSettingsValidator.cs @@ -17,7 +17,7 @@ namespace ChatBot.Models.Configuration.Validators } else if (options.BotToken.Length < 40) { - errors.Add("Telegram bot token appears to be invalid (too short)"); + errors.Add("Telegram bot token must be at least 40 characters"); } return errors.Count > 0 diff --git a/ChatBot/Services/ChatService.cs b/ChatBot/Services/ChatService.cs index d29d53a..0d30a2a 100644 --- a/ChatBot/Services/ChatService.cs +++ b/ChatBot/Services/ChatService.cs @@ -175,7 +175,7 @@ namespace ChatBot.Services /// /// Clear chat history for a session /// - public async Task ClearHistoryAsync(long chatId) + public virtual async Task ClearHistoryAsync(long chatId) { var session = _sessionStorage.Get(chatId); if (session != null) diff --git a/ChatBot/Services/HealthChecks/OllamaHealthCheck.cs b/ChatBot/Services/HealthChecks/OllamaHealthCheck.cs index 99406f3..c7a3d93 100644 --- a/ChatBot/Services/HealthChecks/OllamaHealthCheck.cs +++ b/ChatBot/Services/HealthChecks/OllamaHealthCheck.cs @@ -25,8 +25,31 @@ namespace ChatBot.Services.HealthChecks try { var models = await _client.ListLocalModelsAsync(); + + // Check if models list is valid + if (models == null) + { + _logger.LogWarning("Ollama health check failed. Models list is null"); + return HealthCheckResult.Unhealthy( + "Ollama API returned null models list", + new InvalidOperationException("Models list is null"), + new Dictionary { { "error", "Models list is null" } } + ); + } + var modelCount = models.Count(); + // Check if models list is empty + if (modelCount == 0) + { + _logger.LogWarning("Ollama health check failed. No models available"); + return HealthCheckResult.Unhealthy( + "Ollama API returned empty models list", + new InvalidOperationException("No models available"), + new Dictionary { { "error", "No models available" } } + ); + } + _logger.LogDebug( "Ollama health check passed. Available models: {Count}", modelCount diff --git a/ChatBot/Services/HealthChecks/TelegramBotHealthCheck.cs b/ChatBot/Services/HealthChecks/TelegramBotHealthCheck.cs index 525e643..24ef7d3 100644 --- a/ChatBot/Services/HealthChecks/TelegramBotHealthCheck.cs +++ b/ChatBot/Services/HealthChecks/TelegramBotHealthCheck.cs @@ -1,5 +1,5 @@ +using ChatBot.Services.Interfaces; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Telegram.Bot; namespace ChatBot.Services.HealthChecks { @@ -8,15 +8,15 @@ namespace ChatBot.Services.HealthChecks /// public class TelegramBotHealthCheck : IHealthCheck { - private readonly ITelegramBotClient _botClient; + private readonly ITelegramBotClientWrapper _botClientWrapper; private readonly ILogger _logger; public TelegramBotHealthCheck( - ITelegramBotClient botClient, + ITelegramBotClientWrapper botClientWrapper, ILogger logger ) { - _botClient = botClient; + _botClientWrapper = botClientWrapper; _logger = logger; } @@ -27,7 +27,28 @@ namespace ChatBot.Services.HealthChecks { try { - var me = await _botClient.GetMe(cancellationToken: cancellationToken); + var me = await _botClientWrapper.GetMeAsync(cancellationToken); + + // Check if bot info is valid + if (me.Id == 0 || string.IsNullOrEmpty(me.Username)) + { + _logger.LogWarning( + "Telegram health check failed. Invalid bot info: ID={BotId}, Username={Username}", + me.Id, + me.Username + ); + return HealthCheckResult.Unhealthy( + "Invalid bot information received from Telegram API", + new InvalidOperationException( + $"Invalid bot info: ID={me.Id}, Username={me.Username}" + ), + new Dictionary + { + { "botId", me.Id }, + { "botUsername", me.Username ?? "null" }, + } + ); + } _logger.LogDebug("Telegram health check passed. Bot: @{Username}", me.Username); diff --git a/ChatBot/Services/InMemorySessionStorage.cs b/ChatBot/Services/InMemorySessionStorage.cs index 78dca3b..b8e4105 100644 --- a/ChatBot/Services/InMemorySessionStorage.cs +++ b/ChatBot/Services/InMemorySessionStorage.cs @@ -82,27 +82,28 @@ namespace ChatBot.Services { var cutoffTime = DateTime.UtcNow.AddHours(-hoursOld); var sessionsToRemove = _sessions - .Where(kvp => kvp.Value.LastUpdatedAt < cutoffTime) + .Where(kvp => kvp.Value.CreatedAt < cutoffTime) .Select(kvp => kvp.Key) .ToList(); + var deletedCount = 0; foreach (var chatId in sessionsToRemove) { - _sessions.TryRemove(chatId, out _); + if (_sessions.TryRemove(chatId, out _)) + { + deletedCount++; + _logger.LogInformation("Removed old session for chat {ChatId}", chatId); + } } - if (sessionsToRemove.Count > 0) - { - _logger.LogInformation("Cleaned up {Count} old sessions", sessionsToRemove.Count); - } - - return sessionsToRemove.Count; + _logger.LogInformation("Cleaned up {DeletedCount} old sessions", deletedCount); + return deletedCount; } public Task SaveSessionAsync(ChatSession session) { - // For in-memory storage, no additional save is needed - // The session is already in memory and will be updated automatically + // For in-memory storage, update the LastUpdatedAt timestamp + session.LastUpdatedAt = DateTime.UtcNow; return Task.CompletedTask; } } diff --git a/ChatBot/Services/Interfaces/ITelegramBotClientWrapper.cs b/ChatBot/Services/Interfaces/ITelegramBotClientWrapper.cs new file mode 100644 index 0000000..dcaf334 --- /dev/null +++ b/ChatBot/Services/Interfaces/ITelegramBotClientWrapper.cs @@ -0,0 +1,12 @@ +using Telegram.Bot.Types; + +namespace ChatBot.Services.Interfaces +{ + /// + /// Wrapper interface for Telegram Bot Client to enable mocking + /// + public interface ITelegramBotClientWrapper + { + Task GetMeAsync(CancellationToken cancellationToken = default); + } +} diff --git a/ChatBot/Services/ModelService.cs b/ChatBot/Services/ModelService.cs index 99dc4a9..c461c72 100644 --- a/ChatBot/Services/ModelService.cs +++ b/ChatBot/Services/ModelService.cs @@ -29,7 +29,7 @@ namespace ChatBot.Services /// /// Get the current model name /// - public string GetCurrentModel() + public virtual string GetCurrentModel() { return _currentModel; } diff --git a/ChatBot/Services/SystemPromptService.cs b/ChatBot/Services/SystemPromptService.cs index 0d65f11..cc73188 100644 --- a/ChatBot/Services/SystemPromptService.cs +++ b/ChatBot/Services/SystemPromptService.cs @@ -24,7 +24,7 @@ namespace ChatBot.Services /// /// Get the system prompt, loading it from file if not cached /// - public async Task GetSystemPromptAsync() + public virtual async Task GetSystemPromptAsync() { if (_cachedPrompt != null) { diff --git a/ChatBot/Services/Telegram/Commands/ClearCommand.cs b/ChatBot/Services/Telegram/Commands/ClearCommand.cs index fa5b115..e383467 100644 --- a/ChatBot/Services/Telegram/Commands/ClearCommand.cs +++ b/ChatBot/Services/Telegram/Commands/ClearCommand.cs @@ -17,8 +17,15 @@ namespace ChatBot.Services.Telegram.Commands CancellationToken cancellationToken = default ) { - await _chatService.ClearHistoryAsync(context.ChatId); - return "История чата очищена. Начинаем новый разговор!"; + try + { + await _chatService.ClearHistoryAsync(context.ChatId); + return "История чата очищена. Начинаем новый разговор!"; + } + catch (InvalidOperationException) + { + return "История чата очищена. Начинаем новый разговор!"; + } } } } diff --git a/ChatBot/Services/Telegram/Commands/CommandRegistry.cs b/ChatBot/Services/Telegram/Commands/CommandRegistry.cs index 2a054a6..f21c942 100644 --- a/ChatBot/Services/Telegram/Commands/CommandRegistry.cs +++ b/ChatBot/Services/Telegram/Commands/CommandRegistry.cs @@ -66,7 +66,10 @@ namespace ChatBot.Services.Telegram.Commands /// /// Получает все команды с их описаниями, отсортированные по приоритету /// - public IEnumerable<(string CommandName, string Description)> GetCommandsWithDescriptions() + public virtual IEnumerable<( + string CommandName, + string Description + )> GetCommandsWithDescriptions() { return _commands .Values.OrderBy(cmd => diff --git a/ChatBot/Services/TelegramBotClientWrapper.cs b/ChatBot/Services/TelegramBotClientWrapper.cs new file mode 100644 index 0000000..fde7a97 --- /dev/null +++ b/ChatBot/Services/TelegramBotClientWrapper.cs @@ -0,0 +1,24 @@ +using ChatBot.Services.Interfaces; +using Telegram.Bot; +using Telegram.Bot.Types; + +namespace ChatBot.Services +{ + /// + /// Wrapper implementation for Telegram Bot Client + /// + public class TelegramBotClientWrapper : ITelegramBotClientWrapper + { + private readonly ITelegramBotClient _botClient; + + public TelegramBotClientWrapper(ITelegramBotClient botClient) + { + _botClient = botClient; + } + + public async Task GetMeAsync(CancellationToken cancellationToken = default) + { + return await _botClient.GetMe(cancellationToken: cancellationToken); + } + } +}