From 57e51d0d0bf43152267a2ca579c4f883f8e74376 Mon Sep 17 00:00:00 2001 From: Lasse Rune Hansen Date: Tue, 9 Jun 2026 20:00:14 +0200 Subject: [PATCH] test(backend/infrastructure): add unit tests for MistralConnector - Add Tests/Unit/Infrastructure/Services/MistralConnectorTests.cs with 20 tests - Constructor validation tests - Configuration validation tests - Model factory method tests - Rate limiter tests - Circuit breaker tests - Fix HttpClient Content-Type header issue (use Accept header instead) - All 137 unit tests passing (117 previous + 20 new) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .../Services/MistralConnector.cs | 2 +- .../Services/MistralConnectorTests.cs | 314 ++++++++++++++++++ 2 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 Tests/Unit/Infrastructure/Services/MistralConnectorTests.cs diff --git a/GermanApp/Infrastructure/Services/MistralConnector.cs b/GermanApp/Infrastructure/Services/MistralConnector.cs index cbc2b2f..fd3548b 100644 --- a/GermanApp/Infrastructure/Services/MistralConnector.cs +++ b/GermanApp/Infrastructure/Services/MistralConnector.cs @@ -35,7 +35,7 @@ public class MistralConnector : IMistralConnector _httpClient.BaseAddress = new Uri(_config.BaseUrl); _httpClient.Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds); _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.ApiKey}"); - _httpClient.DefaultRequestHeaders.Add("Content-Type", "application/json"); + _httpClient.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); _jsonOptions = new JsonSerializerOptions { diff --git a/Tests/Unit/Infrastructure/Services/MistralConnectorTests.cs b/Tests/Unit/Infrastructure/Services/MistralConnectorTests.cs new file mode 100644 index 0000000..746ae22 --- /dev/null +++ b/Tests/Unit/Infrastructure/Services/MistralConnectorTests.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using GermanApp.Application.Models; +using GermanApp.Infrastructure.Configuration; +using GermanApp.Infrastructure.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GermanApp.Tests.Unit.Infrastructure.Services; + +[TestClass] +public class MistralConnectorTests +{ + private HttpClient _httpClient; + private MistralConfig _config; + private MistralConnector _connector; + + [TestInitialize] + public void Setup() + { + _config = new MistralConfig + { + ApiKey = "test-api-key", + BaseUrl = "https://api.mistral.ai/v1/", + DefaultModel = "mistral-medium", + TimeoutSeconds = 30, + MaxRetries = 3, + RateLimitPerMinute = 10, + EnableCaching = true, + CacheTTLMinutes = 60, + CircuitBreakerFailureThreshold = 5, + CircuitBreakerResetMinutes = 1 + }; + + _httpClient = new HttpClient(); + _connector = new MistralConnector(_httpClient, _config, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + new Microsoft.Extensions.Caching.Memory.MemoryCache( + Microsoft.Extensions.Options.Options.Create(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()))); + } + + [TestCleanup] + public void Cleanup() + { + _httpClient?.Dispose(); + } + + [TestMethod] + public void Constructor_WithValidParameters_CreatesConnector() + { + Assert.IsNotNull(_connector); + } + + [TestMethod] + public void Constructor_WithEmptyApiKey_ThrowsArgumentException() + { + var invalidConfig = new MistralConfig { ApiKey = "", BaseUrl = "https://api.mistral.ai/v1/" }; + + try + { + new MistralConnector(_httpClient, invalidConfig, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + new Microsoft.Extensions.Caching.Memory.MemoryCache( + Microsoft.Extensions.Options.Options.Create(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()))); + Assert.Fail("Expected ArgumentException was not thrown"); + } + catch (ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void Constructor_WithInvalidBaseUrl_ThrowsArgumentException() + { + var invalidConfig = new MistralConfig { ApiKey = "key", BaseUrl = "not-a-url" }; + + try + { + new MistralConnector(_httpClient, invalidConfig, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + new Microsoft.Extensions.Caching.Memory.MemoryCache( + Microsoft.Extensions.Options.Options.Create(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()))); + Assert.Fail("Expected ArgumentException was not thrown"); + } + catch (ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void Constructor_ConfiguresHttpClient() + { + Assert.AreEqual(new Uri("https://api.mistral.ai/v1/"), _httpClient.BaseAddress); + Assert.AreEqual(TimeSpan.FromSeconds(30), _httpClient.Timeout); + Assert.AreEqual("Bearer test-api-key", _httpClient.DefaultRequestHeaders.Authorization?.ToString()); + Assert.IsTrue(_httpClient.DefaultRequestHeaders.Accept.Any(h => h.MediaType == "application/json")); + } + + [TestMethod] + public void MistralConfig_Validate_WithValidConfig_Passes() + { + var config = new MistralConfig + { + ApiKey = "valid-key", + BaseUrl = "https://api.mistral.ai/v1/" + }; + + // Should not throw + config.Validate(); + } + + [TestMethod] + public void MistralConfig_Validate_WithEmptyApiKey_Throws() + { + var config = new MistralConfig { ApiKey = "", BaseUrl = "https://api.mistral.ai/v1/" }; + + try + { + config.Validate(); + Assert.Fail("Expected ArgumentException was not thrown"); + } + catch (ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void MistralConfig_Validate_WithInvalidTimeout_Throws() + { + var config = new MistralConfig { ApiKey = "key", BaseUrl = "https://api.mistral.ai/v1/", TimeoutSeconds = 0 }; + + try + { + config.Validate(); + Assert.Fail("Expected ArgumentException was not thrown"); + } + catch (ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void MistralConfig_Validate_WithNegativeMaxRetries_Throws() + { + var config = new MistralConfig { ApiKey = "key", BaseUrl = "https://api.mistral.ai/v1/", MaxRetries = -1 }; + + try + { + config.Validate(); + Assert.Fail("Expected ArgumentException was not thrown"); + } + catch (ArgumentException) + { + // Expected + } + } + + [TestMethod] + public void MistralRequest_CreateCompletion_CreatesRequest() + { + var request = MistralRequest.CreateCompletion("Test prompt", "mistral-medium", 512); + + Assert.AreEqual("Test prompt", request.Prompt); + Assert.AreEqual("mistral-medium", request.Model); + Assert.AreEqual(512, request.MaxTokens); + Assert.AreEqual(0.7, request.Temperature); + } + + [TestMethod] + public void MistralChatRequest_CreateChat_CreatesRequest() + { + var messages = new List + { + MistralMessage.User("Hello") + }; + var request = MistralChatRequest.CreateChat(messages, "mistral-medium", 512); + + Assert.AreEqual(1, request.Messages.Count); + Assert.AreEqual("user", request.Messages[0].Role); + Assert.AreEqual("Hello", request.Messages[0].Content); + } + + [TestMethod] + public void MistralMessage_FactoryMethods_CreateMessages() + { + var userMsg = MistralMessage.User("User message"); + var assistantMsg = MistralMessage.Assistant("Assistant message"); + var systemMsg = MistralMessage.System("System message"); + + Assert.AreEqual("user", userMsg.Role); + Assert.AreEqual("User message", userMsg.Content); + + Assert.AreEqual("assistant", assistantMsg.Role); + Assert.AreEqual("Assistant message", assistantMsg.Content); + + Assert.AreEqual("system", systemMsg.Role); + Assert.AreEqual("System message", systemMsg.Content); + } + + [TestMethod] + public void MistralResponse_HasValidChoices_WithEmptyChoices_ReturnsFalse() + { + var response = new MistralResponse + { + Model = "mistral-medium", + Choices = new List(), + Usage = new MistralUsage() + }; + + Assert.IsFalse(response.HasValidChoices); + } + + [TestMethod] + public void MistralResponse_HasValidChoices_WithText_ReturnsTrue() + { + var choices = new List + { + new MistralChoice { Text = "Test completion", Index = 0 } + }; + var response = new MistralResponse + { + Model = "mistral-medium", + Choices = choices, + Usage = new MistralUsage() + }; + + Assert.IsTrue(response.HasValidChoices); + Assert.AreEqual("Test completion", response.FirstChoiceText); + } + + [TestMethod] + public void MistralUsage_EstimatedCost_CalculatesCorrectly() + { + var usage = new MistralUsage + { + PromptTokens = 100, + CompletionTokens = 50, + TotalTokens = 150 + }; + + // Approx $0.0000025 per token for mistral-medium + decimal expectedCost = 150 * 0.0000025m; + Assert.AreEqual(expectedCost, usage.EstimatedCost); + } + + [TestMethod] + public void MistralRateLimiter_TryAcquire_WithinLimit_ReturnsTrue() + { + var limiter = new MistralRateLimiter(10); + + for (int i = 0; i < 10; i++) + { + Assert.IsTrue(limiter.TryAcquire("test")); + } + } + + [TestMethod] + public void MistralRateLimiter_TryAcquire_ExceedsLimit_ReturnsFalse() + { + var limiter = new MistralRateLimiter(2); + + Assert.IsTrue(limiter.TryAcquire("test")); + Assert.IsTrue(limiter.TryAcquire("test")); + Assert.IsFalse(limiter.TryAcquire("test")); + } + + [TestMethod] + public void MistralCircuitBreaker_IsClosed_Initially_ReturnsTrue() + { + var breaker = new MistralCircuitBreaker(5, TimeSpan.FromMinutes(1)); + Assert.IsTrue(breaker.IsClosed); + } + + [TestMethod] + public void MistralCircuitBreaker_AfterFailures_Opens() + { + var breaker = new MistralCircuitBreaker(2, TimeSpan.FromMinutes(1)); + + breaker.RecordFailure(); + breaker.RecordFailure(); + + Assert.IsFalse(breaker.IsClosed); + } + + [TestMethod] + public void MistralCircuitBreaker_AfterSuccess_Resets() + { + var breaker = new MistralCircuitBreaker(2, TimeSpan.FromMinutes(1)); + + breaker.RecordFailure(); + breaker.RecordSuccess(); + + Assert.IsTrue(breaker.IsClosed); + } + + [TestMethod] + public void MistralCircuitBreaker_AfterResetTimeout_ResetsAutomatically() + { + var breaker = new MistralCircuitBreaker(1, TimeSpan.FromMilliseconds(10)); + + breaker.RecordFailure(); + Assert.IsFalse(breaker.IsClosed); + + // Wait for reset + System.Threading.Tasks.Task.Delay(20).Wait(); + + Assert.IsTrue(breaker.IsClosed); + } +}