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);
+ }
+ }
+}