diff --git a/Brainarr.Plugin/Services/Caching/EnhancedRecommendationCache.cs b/Brainarr.Plugin/Services/Caching/EnhancedRecommendationCache.cs index c9b342c6..d01edfd5 100644 --- a/Brainarr.Plugin/Services/Caching/EnhancedRecommendationCache.cs +++ b/Brainarr.Plugin/Services/Caching/EnhancedRecommendationCache.cs @@ -1,4 +1,4 @@ -#if BRAINARR_EXPERIMENTAL_CACHE +#if BRAINARR_EXPERIMENTAL_CACHE using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -806,3 +806,4 @@ private void RecordAccessTime(double milliseconds) } } #endif + diff --git a/Brainarr.Tests/RateLimiting/EnhancedRateLimiterTests.cs b/Brainarr.Tests/RateLimiting/EnhancedRateLimiterTests.cs index f723cdba..fa811227 100644 --- a/Brainarr.Tests/RateLimiting/EnhancedRateLimiterTests.cs +++ b/Brainarr.Tests/RateLimiting/EnhancedRateLimiterTests.cs @@ -1,54 +1,98 @@ -using System; +using System; + using System.Net; + using System.Threading.Tasks; + using NLog; + using NzbDrone.Core.ImportLists.Brainarr.Services.RateLimiting; + using Xunit; + namespace Brainarr.Tests.RateLimiting + { + public class EnhancedRateLimiterTests + { + private static Logger TestLogger => LogManager.GetCurrentClassLogger(); + [Fact] + public async Task Allows_first_request_then_blocks_second_with_tight_policy() + { + var limiter = new EnhancedRateLimiter(TestLogger); + limiter.ConfigureLimit("test", new RateLimitPolicy + { + MaxRequests = 1, + Period = TimeSpan.FromSeconds(5), + EnableUserLimit = false, + EnableIpLimit = false, + EnableResourceLimit = true + }); + var req = new RateLimitRequest { Resource = "test", UserId = "u1", IpAddress = IPAddress.Loopback }; + var first = await limiter.CheckRateLimitAsync(req); + Assert.True(first.IsAllowed); + // Consume and then check again + await limiter.ExecuteAsync(req, async () => 1); + var second = await limiter.CheckRateLimitAsync(req); + Assert.False(second.IsAllowed); + Assert.True(second.RetryAfter.HasValue); + } + [Fact] + public void TokenBucket_refills_over_time() + { + var bucket = new TokenBucket(2, TimeSpan.FromMilliseconds(200)); + Assert.True(bucket.TryConsume()); + Assert.True(bucket.TryConsume()); + Assert.False(bucket.TryConsume()); + // Wait for refill + var wait = bucket.GetWaitTime(1); + Assert.True(wait > TimeSpan.Zero); + } + } + } - + diff --git a/Brainarr.Tests/Services/Core/ModelActionHandlerDetailsTests.cs b/Brainarr.Tests/Services/Core/ModelActionHandlerDetailsTests.cs index 1f094649..3257a63e 100644 --- a/Brainarr.Tests/Services/Core/ModelActionHandlerDetailsTests.cs +++ b/Brainarr.Tests/Services/Core/ModelActionHandlerDetailsTests.cs @@ -1,75 +1,141 @@ -using System; +using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using FluentAssertions; + using NLog; + using NzbDrone.Common.Http; + using NzbDrone.Core.ImportLists.Brainarr; + using NzbDrone.Core.ImportLists.Brainarr.Configuration; + using NzbDrone.Core.ImportLists.Brainarr.Models; + using NzbDrone.Core.ImportLists.Brainarr.Services; + using NzbDrone.Core.ImportLists.Brainarr.Services.Core; + using Xunit; + namespace Brainarr.Tests.Services.Core + { + public class ModelActionHandlerDetailsTests + { + private class FakeProviderWithHint : IAIProvider + { + public string ProviderName => "Google Gemini"; + public Task> GetRecommendationsAsync(string prompt) => Task.FromResult(new List()); + public Task TestConnectionAsync() => Task.FromResult(false); + public void UpdateModel(string modelName) { } + public string? GetLastUserMessage() => "Gemini API disabled for this key's Google Cloud project. Enable the Generative Language API: https://console.developers.google.com/apis/api/generativelanguage.googleapis.com/overview?project=123"; + } + private class FakeProviderFactory : IProviderFactory + { + private readonly IAIProvider _provider; + public FakeProviderFactory(IAIProvider provider) { _provider = provider; } + public IAIProvider CreateProvider(BrainarrSettings settings, IHttpClient httpClient, Logger logger) => _provider; + public bool IsProviderAvailable(AIProvider providerType, BrainarrSettings settings) => true; + } + private class NoopHttpClient : IHttpClient + { + public Task ExecuteAsync(HttpRequest request) => Task.FromResult(new HttpResponse(request, new HttpHeader(), "{}")); + public HttpResponse Execute(HttpRequest request) => new HttpResponse(request, new HttpHeader(), "{}"); + public void DownloadFile(string url, string fileName) => throw new NotImplementedException(); + public Task DownloadFileAsync(string url, string fileName) => throw new NotImplementedException(); + public HttpResponse Get(HttpRequest request) => Execute(request); + public Task GetAsync(HttpRequest request) => ExecuteAsync(request); + public HttpResponse Get(HttpRequest request) where T : new() => throw new NotImplementedException(); + public Task> GetAsync(HttpRequest request) where T : new() => throw new NotImplementedException(); + public HttpResponse Head(HttpRequest request) => Execute(request); + public Task HeadAsync(HttpRequest request) => ExecuteAsync(request); + public HttpResponse Post(HttpRequest request) => Execute(request); + public Task PostAsync(HttpRequest request) => ExecuteAsync(request); + public HttpResponse Post(HttpRequest request) where T : new() => throw new NotImplementedException(); + public Task> PostAsync(HttpRequest request) where T : new() => throw new NotImplementedException(); + } + private static Logger L => LogManager.GetCurrentClassLogger(); + [Fact] + public async Task HandleTestConnectionDetailsAsync_surfaces_hint_on_failure() + { + var settings = new BrainarrSettings { Provider = AIProvider.Gemini }; + var provider = new FakeProviderWithHint(); + var handler = new ModelActionHandler( + new ModelDetectionService(new NoopHttpClient(), L), + new FakeProviderFactory(provider), + new NoopHttpClient(), + L); + var result = await handler.HandleTestConnectionDetailsAsync(settings); + result.Success.Should().BeFalse(); + result.Provider.Should().Be("Google Gemini"); + result.Hint.Should().NotBeNull(); + result.Hint.Should().Contain("Enable the Generative Language API"); + } + } + } - +