diff --git a/src/GovUK.Dfe.CoreLibs.Caching/GovUK.Dfe.CoreLibs.Caching.csproj b/src/GovUK.Dfe.CoreLibs.Caching/GovUK.Dfe.CoreLibs.Caching.csproj index 1235c907..7058abc7 100644 --- a/src/GovUK.Dfe.CoreLibs.Caching/GovUK.Dfe.CoreLibs.Caching.csproj +++ b/src/GovUK.Dfe.CoreLibs.Caching/GovUK.Dfe.CoreLibs.Caching.csproj @@ -16,11 +16,13 @@ + - + + diff --git a/src/GovUK.Dfe.CoreLibs.Caching/Helpers/CacheKeyHelper.cs b/src/GovUK.Dfe.CoreLibs.Caching/Helpers/CacheKeyHelper.cs index 250fff23..4d9373ab 100644 --- a/src/GovUK.Dfe.CoreLibs.Caching/Helpers/CacheKeyHelper.cs +++ b/src/GovUK.Dfe.CoreLibs.Caching/Helpers/CacheKeyHelper.cs @@ -38,6 +38,52 @@ public static string GenerateHashedCacheKey(IEnumerable inputs) return GenerateHashedCacheKey(concatenatedInput); } + + /// + /// Computes the SHA-256 hash of the provided stream and returns it as an uppercase hexadecimal string. + /// + /// + /// The stream to compute the hash for. The stream must be readable and positioned + /// at the beginning of the data to hash. + /// + /// + /// A 64-character uppercase hexadecimal string representing the SHA-256 hash of the stream’s contents. + /// + /// Thrown if is . + /// Thrown if the stream cannot be read. + public static string ComputeSha256(Stream fileStream) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream), "File stream cannot be null."); + + if (!fileStream.CanRead) + throw new ArgumentException("File stream must be readable.", nameof(fileStream)); + + // Remember the original position if the stream supports seeking. + long originalPosition = 0; + if (fileStream.CanSeek) + { + originalPosition = fileStream.Position; + fileStream.Position = 0; + } + + try + { + using var sha = SHA256.Create(); + var hashBytes = sha.ComputeHash(fileStream); + + // Convert the hash bytes to an uppercase hexadecimal string. + return Convert.ToHexString(hashBytes); + } + finally + { + // Restore the stream position so callers can continue using it if needed. + if (fileStream.CanSeek) + { + fileStream.Position = originalPosition; + } + } + } } } diff --git a/src/GovUK.Dfe.CoreLibs.Caching/Interfaces/ICacheService.cs b/src/GovUK.Dfe.CoreLibs.Caching/Interfaces/ICacheService.cs index 72957943..c523aa3f 100644 --- a/src/GovUK.Dfe.CoreLibs.Caching/Interfaces/ICacheService.cs +++ b/src/GovUK.Dfe.CoreLibs.Caching/Interfaces/ICacheService.cs @@ -2,7 +2,8 @@ namespace GovUK.Dfe.CoreLibs.Caching.Interfaces { public interface ICacheService where TCacheType : ICacheType { - Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName); + Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName, CancellationToken cancellationToken = default); + Task GetAsync(string cacheKey); void Remove(string cacheKey); Type CacheType { get; } } diff --git a/src/GovUK.Dfe.CoreLibs.Caching/Interfaces/IRedisCacheType.cs b/src/GovUK.Dfe.CoreLibs.Caching/Interfaces/IRedisCacheType.cs new file mode 100644 index 00000000..5696969a --- /dev/null +++ b/src/GovUK.Dfe.CoreLibs.Caching/Interfaces/IRedisCacheType.cs @@ -0,0 +1,3 @@ +namespace GovUK.Dfe.CoreLibs.Caching.Interfaces; + +public interface IRedisCacheType : ICacheType; \ No newline at end of file diff --git a/src/GovUK.Dfe.CoreLibs.Caching/ServiceCollectionExtensions.cs b/src/GovUK.Dfe.CoreLibs.Caching/ServiceCollectionExtensions.cs index d1f7e7d1..1f5ce074 100644 --- a/src/GovUK.Dfe.CoreLibs.Caching/ServiceCollectionExtensions.cs +++ b/src/GovUK.Dfe.CoreLibs.Caching/ServiceCollectionExtensions.cs @@ -2,18 +2,78 @@ using GovUK.Dfe.CoreLibs.Caching.Services; using GovUK.Dfe.CoreLibs.Caching.Settings; using Microsoft.Extensions.Configuration; +using StackExchange.Redis; namespace Microsoft.Extensions.DependencyInjection { public static class ServiceCollectionExtensions { + private static string GetRedisConnectionString(IConfiguration config) + { + // First, check ConnectionStrings:Redis + var connectionString = config.GetConnectionString("Redis"); + + if (!string.IsNullOrEmpty(connectionString)) + { + return connectionString; + } + + // Fall back to CacheSettings:Redis:ConnectionString + var cacheSettings = config.GetSection("CacheSettings:Redis").Get(); + if (!string.IsNullOrEmpty(cacheSettings?.ConnectionString)) + { + return cacheSettings.ConnectionString; + } + + throw new InvalidOperationException( + "Redis connection string is required but not configured. " + + "Please configure either 'ConnectionStrings:Redis' or 'CacheSettings:Redis:ConnectionString'"); + } public static IServiceCollection AddServiceCaching( this IServiceCollection services, IConfiguration config) { services.Configure(config.GetSection("CacheSettings")); + services.AddMemoryCache(); + services.AddSingleton, MemoryCacheService>(); + + return services; + } + + public static IServiceCollection AddRedisCaching( + this IServiceCollection services, IConfiguration config) + { + services.Configure(config.GetSection("CacheSettings")); + + // Configure Redis connection + services.AddSingleton(serviceProvider => + { + var connectionString = GetRedisConnectionString(config); + return ConnectionMultiplexer.Connect(connectionString); + }); + + services.AddSingleton, RedisCacheService>(); + + return services; + } + + public static IServiceCollection AddHybridCaching( + this IServiceCollection services, IConfiguration config) + { + services.Configure(config.GetSection("CacheSettings")); + + // Add Memory Cache + services.AddMemoryCache(); services.AddSingleton, MemoryCacheService>(); + // Add Redis Cache + services.AddSingleton(serviceProvider => + { + var connectionString = GetRedisConnectionString(config); + return ConnectionMultiplexer.Connect(connectionString); + }); + services.AddSingleton, RedisCacheService>(); + return services; } } -} +} \ No newline at end of file diff --git a/src/GovUK.Dfe.CoreLibs.Caching/Services/MemoryCacheService.cs b/src/GovUK.Dfe.CoreLibs.Caching/Services/MemoryCacheService.cs index 31116c04..fb44a249 100644 --- a/src/GovUK.Dfe.CoreLibs.Caching/Services/MemoryCacheService.cs +++ b/src/GovUK.Dfe.CoreLibs.Caching/Services/MemoryCacheService.cs @@ -15,7 +15,7 @@ public class MemoryCacheService( private readonly MemoryCacheSettings _cacheSettings = cacheSettings.Value.Memory; public Type CacheType => typeof(IMemoryCacheType); - public async Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName) + public async Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName, CancellationToken cancellationToken = default) { if (memoryCache.TryGetValue(cacheKey, out T? cachedValue)) { @@ -23,6 +23,8 @@ public async Task GetOrAddAsync(string cacheKey, Func> fetchFuncti return cachedValue!; } + cancellationToken.ThrowIfCancellationRequested(); + logger.LogInformation("Cache miss for key: {CacheKey}. Fetching from source...", cacheKey); var result = await fetchFunction(); @@ -34,6 +36,20 @@ public async Task GetOrAddAsync(string cacheKey, Func> fetchFuncti return result; } + public async Task GetAsync(string cacheKey) + { + await Task.CompletedTask; // Keep async signature for interface consistency + + if (memoryCache.TryGetValue(cacheKey, out T? cachedValue)) + { + logger.LogInformation("Cache hit for key: {CacheKey}", cacheKey); + return cachedValue; + } + + logger.LogInformation("Cache miss for key: {CacheKey}", cacheKey); + return default(T); + } + public void Remove(string cacheKey) { memoryCache.Remove(cacheKey); diff --git a/src/GovUK.Dfe.CoreLibs.Caching/Services/RedisCacheService.cs b/src/GovUK.Dfe.CoreLibs.Caching/Services/RedisCacheService.cs new file mode 100644 index 00000000..01b0e3fd --- /dev/null +++ b/src/GovUK.Dfe.CoreLibs.Caching/Services/RedisCacheService.cs @@ -0,0 +1,300 @@ +using System.Text.Json; +using GovUK.Dfe.CoreLibs.Caching.Interfaces; +using GovUK.Dfe.CoreLibs.Caching.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace GovUK.Dfe.CoreLibs.Caching.Services +{ + public class RedisCacheService( + IConnectionMultiplexer connectionMultiplexer, + ILogger logger, + IOptions cacheSettings) + : ICacheService + { + private readonly RedisCacheSettings _cacheSettings = cacheSettings.Value.Redis; + private readonly IDatabase _database = connectionMultiplexer.GetDatabase(cacheSettings.Value.Redis.Database); + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + private const int LockExpirySeconds = 30; + private const int LockRetryDelayMilliseconds = 50; + private const int MaxLockRetries = 100; + + public Type CacheType => typeof(IRedisCacheType); + + public async Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName, CancellationToken cancellationToken = default) + { + var fullKey = GetFullKey(cacheKey); + + try + { + // First check: try to get from cache without lock + var cachedValue = await _database.StringGetAsync(fullKey); + if (cachedValue.HasValue) + { + logger.LogInformation("Redis cache hit for key: {CacheKey}", fullKey); + var deserializedValue = JsonSerializer.Deserialize(cachedValue!, _jsonSerializerOptions); + return deserializedValue!; + } + + // Cache miss - acquire distributed lock to prevent race conditions + var lockKey = $"{fullKey}:lock"; + var lockToken = Guid.NewGuid().ToString(); + + var lockAcquired = await TryAcquireLockAsync(lockKey, lockToken, cancellationToken); + + if (lockAcquired) + { + try + { + // Double-check: another thread might have populated the cache while we were waiting for the lock + cachedValue = await _database.StringGetAsync(fullKey); + if (cachedValue.HasValue) + { + logger.LogInformation("Redis cache hit after lock acquisition for key: {CacheKey}", fullKey); + var deserializedValue = JsonSerializer.Deserialize(cachedValue!, _jsonSerializerOptions); + return deserializedValue!; + } + + logger.LogInformation("Redis cache miss for key: {CacheKey}. Fetching from source...", fullKey); + var result = await fetchFunction(); + + if (!Equals(result, default(T))) + { + var cacheDuration = GetCacheDurationForMethod(methodName); + var serializedValue = JsonSerializer.Serialize(result, _jsonSerializerOptions); + await _database.StringSetAsync(fullKey, serializedValue, cacheDuration); + logger.LogInformation("Redis cached result for key: {CacheKey} for duration: {CacheDuration}", fullKey, cacheDuration); + } + + return result; + } + finally + { + // Release the lock + await ReleaseLockAsync(lockKey, lockToken, cancellationToken); + } + } + else + { + // Could not acquire lock - wait and retry reading from cache + logger.LogInformation("Waiting for another thread to populate cache for key: {CacheKey}", fullKey); + + for (int i = 0; i < MaxLockRetries; i++) + { + await Task.Delay(LockRetryDelayMilliseconds, cancellationToken); + + cachedValue = await _database.StringGetAsync(fullKey); + if (cachedValue.HasValue) + { + logger.LogInformation("Redis cache hit after waiting for key: {CacheKey}", fullKey); + var deserializedValue = JsonSerializer.Deserialize(cachedValue!, _jsonSerializerOptions); + return deserializedValue!; + } + } + + // Fallback: if we still don't have the value after max retries, fetch it ourselves + logger.LogWarning("Max retries reached waiting for cache population for key: {CacheKey}. Fetching directly.", fullKey); + return await fetchFunction(); + } + } + catch (RedisException ex) + { + logger.LogError(ex, "Redis error occurred for key: {CacheKey}. Falling back to fetch function", fullKey); + return await fetchFunction(); + } + catch (JsonException ex) + { + logger.LogError(ex, "JSON serialization error for key: {CacheKey}. Removing invalid cache entry", fullKey); + await _database.KeyDeleteAsync(fullKey); + return await fetchFunction(); + } + } + + public async Task GetAsync(string cacheKey) + { + var fullKey = GetFullKey(cacheKey); + + try + { + var cachedValue = await _database.StringGetAsync(fullKey); + if (cachedValue.HasValue) + { + logger.LogInformation("Redis cache hit for key: {CacheKey}", fullKey); + var deserializedValue = JsonSerializer.Deserialize(cachedValue!, _jsonSerializerOptions); + return deserializedValue; + } + + logger.LogInformation("Redis cache miss for key: {CacheKey}", fullKey); + return default(T); + } + catch (RedisException ex) + { + logger.LogError(ex, "Redis error occurred for key: {CacheKey}. Returning default value", fullKey); + return default(T); + } + catch (JsonException ex) + { + logger.LogError(ex, "JSON deserialization error for key: {CacheKey}. Removing invalid cache entry", fullKey); + await _database.KeyDeleteAsync(fullKey); + return default(T); + } + } + + public void Remove(string cacheKey) + { + var fullKey = GetFullKey(cacheKey); + try + { + _database.KeyDelete(fullKey); + logger.LogInformation("Redis cache removed for key: {CacheKey}", fullKey); + } + catch (RedisException ex) + { + logger.LogError(ex, "Redis error occurred while removing key: {CacheKey}", fullKey); + } + } + + public async Task RemoveAsync(string cacheKey) + { + var fullKey = GetFullKey(cacheKey); + try + { + await _database.KeyDeleteAsync(fullKey); + logger.LogInformation("Redis cache removed for key: {CacheKey}", fullKey); + } + catch (RedisException ex) + { + logger.LogError(ex, "Redis error occurred while removing key: {CacheKey}", fullKey); + } + } + + public async Task RemoveByPatternAsync(string pattern) + { + var fullPattern = GetFullKey(pattern); + try + { + var server = connectionMultiplexer.GetServer(connectionMultiplexer.GetEndPoints()[0]); + var keys = server.Keys(database: _cacheSettings.Database, pattern: fullPattern); + + foreach (var key in keys) + { + await _database.KeyDeleteAsync(key); + } + + logger.LogInformation("Redis cache entries removed for pattern: {Pattern}", fullPattern); + } + catch (RedisException ex) + { + logger.LogError(ex, "Redis error occurred while removing keys by pattern: {Pattern}", fullPattern); + } + } + + private string GetFullKey(string key) + { + return $"{_cacheSettings.KeyPrefix}{key}"; + } + + private TimeSpan GetCacheDurationForMethod(string methodName) + { + if (_cacheSettings.Durations.TryGetValue(methodName, out int durationInSeconds)) + { + return TimeSpan.FromSeconds(durationInSeconds); + } + return TimeSpan.FromSeconds(_cacheSettings.DefaultDurationInSeconds); + } + + /// + /// Attempts to acquire a distributed lock using Redis SETNX command. + /// + /// The key to use for the lock. + /// A unique token to identify this lock owner. + /// Cancellation token to cancel the operation. + /// True if the lock was acquired, false otherwise. + private async Task TryAcquireLockAsync(string lockKey, string lockToken, CancellationToken cancellationToken = default) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + // Use SET with NX (only set if not exists) and EX (expiry) options + var acquired = await _database.StringSetAsync( + lockKey, + lockToken, + TimeSpan.FromSeconds(LockExpirySeconds), + When.NotExists); + + if (acquired) + { + logger.LogDebug("Lock acquired for key: {LockKey}", lockKey); + } + else + { + logger.LogDebug("Failed to acquire lock for key: {LockKey}", lockKey); + } + + return acquired; + } + catch (OperationCanceledException) + { + logger.LogInformation("Lock acquisition cancelled for key: {LockKey}", lockKey); + throw; + } + catch (RedisException ex) + { + logger.LogError(ex, "Error acquiring lock for key: {LockKey}", lockKey); + return false; + } + } + + /// + /// Releases a distributed lock using a Lua script to ensure only the lock owner can release it. + /// + /// The key used for the lock. + /// The unique token that was used to acquire the lock. + /// Cancellation token to cancel the operation. + private async Task ReleaseLockAsync(string lockKey, string lockToken, CancellationToken cancellationToken = default) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + // Lua script to atomically check and delete the lock only if the token matches + const string luaScript = @" + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end"; + + var result = await _database.ScriptEvaluateAsync( + luaScript, + new RedisKey[] { lockKey }, + new RedisValue[] { lockToken }); + + if ((int)result == 1) + { + logger.LogDebug("Lock released for key: {LockKey}", lockKey); + } + else + { + logger.LogWarning("Failed to release lock for key: {LockKey}. Lock may have expired or been acquired by another process.", lockKey); + } + } + catch (OperationCanceledException) + { + logger.LogInformation("Lock release cancelled for key: {LockKey}", lockKey); + // Don't rethrow here - we're in cleanup, best effort to release the lock + } + catch (RedisException ex) + { + logger.LogError(ex, "Error releasing lock for key: {LockKey}", lockKey); + } + } + } +} \ No newline at end of file diff --git a/src/GovUK.Dfe.CoreLibs.Caching/Settings/CacheSettings.cs b/src/GovUK.Dfe.CoreLibs.Caching/Settings/CacheSettings.cs index 209e31e5..a81d252a 100644 --- a/src/GovUK.Dfe.CoreLibs.Caching/Settings/CacheSettings.cs +++ b/src/GovUK.Dfe.CoreLibs.Caching/Settings/CacheSettings.cs @@ -3,6 +3,7 @@ namespace GovUK.Dfe.CoreLibs.Caching.Settings public class CacheSettings { public MemoryCacheSettings Memory { get; set; } = new(); + public RedisCacheSettings Redis { get; set; } = new(); } public class MemoryCacheSettings @@ -10,4 +11,13 @@ public class MemoryCacheSettings public int DefaultDurationInSeconds { get; set; } = 5; public Dictionary Durations { get; set; } = new(); } + + public class RedisCacheSettings + { + public string ConnectionString { get; set; } = string.Empty; + public int DefaultDurationInSeconds { get; set; } = 300; // 5 minutes default for Redis + public Dictionary Durations { get; set; } = new(); + public string KeyPrefix { get; set; } = "DfE:Cache:"; + public int Database { get; set; } = 0; + } } diff --git a/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Extensions/ServiceCollectionExtensionsTests.cs b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Extensions/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..4ef033bb --- /dev/null +++ b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,326 @@ +using GovUK.Dfe.CoreLibs.Caching.Interfaces; +using GovUK.Dfe.CoreLibs.Caching.Services; +using GovUK.Dfe.CoreLibs.Caching.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace GovUK.Dfe.CoreLibs.Caching.Tests.Extensions +{ + public class ServiceCollectionExtensionsTests + { + [Fact] + public void AddServiceCaching_ShouldRegisterMemoryCacheServices() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Memory:DefaultDurationInSeconds"] = "60" + }) + .Build(); + + // Act + services.AddServiceCaching(configuration); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var cacheService = serviceProvider.GetService>(); + Assert.NotNull(cacheService); + Assert.IsType(cacheService); + } + + [Fact] + public void AddRedisCaching_ShouldRegisterRedisCacheServices_WithCacheSettingsConnectionString() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Redis:ConnectionString"] = "localhost:6379", + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "300" + }) + .Build(); + + // Act + services.AddRedisCaching(configuration); + + // Assert - Verify services are registered without triggering actual Redis connection + var serviceProvider = services.BuildServiceProvider(); + var serviceDescriptors = services.ToList(); + + // Verify IConnectionMultiplexer is registered as singleton + var connectionMultiplexerDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(IConnectionMultiplexer)); + Assert.NotNull(connectionMultiplexerDescriptor); + Assert.Equal(ServiceLifetime.Singleton, connectionMultiplexerDescriptor.Lifetime); + + // Verify ICacheService is registered as singleton + var cacheServiceDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(ICacheService)); + Assert.NotNull(cacheServiceDescriptor); + Assert.Equal(ServiceLifetime.Singleton, cacheServiceDescriptor.Lifetime); + Assert.Equal(typeof(RedisCacheService), cacheServiceDescriptor.ImplementationType); + } + + [Fact] + public void AddRedisCaching_ShouldRegisterRedisCacheServices_WithConnectionStringsRedis() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Redis"] = "localhost:6379", + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "300" + }) + .Build(); + + // Act + services.AddRedisCaching(configuration); + + // Assert - Verify services are registered without triggering actual Redis connection + var serviceProvider = services.BuildServiceProvider(); + var serviceDescriptors = services.ToList(); + + // Verify IConnectionMultiplexer is registered as singleton + var connectionMultiplexerDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(IConnectionMultiplexer)); + Assert.NotNull(connectionMultiplexerDescriptor); + Assert.Equal(ServiceLifetime.Singleton, connectionMultiplexerDescriptor.Lifetime); + + // Verify ICacheService is registered as singleton + var cacheServiceDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(ICacheService)); + Assert.NotNull(cacheServiceDescriptor); + Assert.Equal(ServiceLifetime.Singleton, cacheServiceDescriptor.Lifetime); + Assert.Equal(typeof(RedisCacheService), cacheServiceDescriptor.ImplementationType); + } + + [Fact] + public void AddRedisCaching_ShouldPrioritizeConnectionStringsRedis_OverCacheSettings() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Redis"] = "priority:6379", + ["CacheSettings:Redis:ConnectionString"] = "fallback:6379", + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "300" + }) + .Build(); + + // Act + services.AddRedisCaching(configuration); + + // Assert - Verify services are registered without triggering actual Redis connection + var serviceProvider = services.BuildServiceProvider(); + + // Check that the required services are registered + var serviceDescriptors = services.ToList(); + + // Verify IConnectionMultiplexer is registered as singleton + var connectionMultiplexerDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(IConnectionMultiplexer)); + Assert.NotNull(connectionMultiplexerDescriptor); + Assert.Equal(ServiceLifetime.Singleton, connectionMultiplexerDescriptor.Lifetime); + + // Verify ICacheService is registered as singleton + var cacheServiceDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(ICacheService)); + Assert.NotNull(cacheServiceDescriptor); + Assert.Equal(ServiceLifetime.Singleton, cacheServiceDescriptor.Lifetime); + Assert.Equal(typeof(RedisCacheService), cacheServiceDescriptor.ImplementationType); + + // Note: We cannot verify which connection string is used without making a real Redis connection + // The priority logic is tested in the GetRedisConnectionString method indirectly through other tests + } + + [Fact] + public void AddRedisCaching_ShouldThrowException_WhenConnectionStringIsEmpty() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Redis:ConnectionString"] = "", + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "300" + }) + .Build(); + + // Act & Assert + services.AddRedisCaching(configuration); + var serviceProvider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => + serviceProvider.GetService()); + + Assert.Contains("Redis connection string is required", exception.Message); + Assert.Contains("ConnectionStrings:Redis", exception.Message); + Assert.Contains("CacheSettings:Redis:ConnectionString", exception.Message); + } + + [Fact] + public void AddRedisCaching_ShouldThrowException_WhenConnectionStringIsNull() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "300" + }) + .Build(); + + // Act & Assert + services.AddRedisCaching(configuration); + var serviceProvider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => + serviceProvider.GetService()); + + Assert.Contains("Redis connection string is required", exception.Message); + Assert.Contains("ConnectionStrings:Redis", exception.Message); + Assert.Contains("CacheSettings:Redis:ConnectionString", exception.Message); + } + + [Fact] + public void AddHybridCaching_ShouldRegisterBothMemoryAndRedisCacheServices() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Memory:DefaultDurationInSeconds"] = "60", + ["CacheSettings:Redis:ConnectionString"] = "localhost:6379", + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "300" + }) + .Build(); + + // Act + services.AddHybridCaching(configuration); + + // Assert - Verify services are registered without triggering actual Redis connection + var serviceProvider = services.BuildServiceProvider(); + var serviceDescriptors = services.ToList(); + + // Verify memory cache service can be resolved (no Redis connection needed) + var memoryCacheService = serviceProvider.GetService>(); + Assert.NotNull(memoryCacheService); + Assert.IsType(memoryCacheService); + + // Verify Redis services are registered (but don't resolve to avoid connection) + var connectionMultiplexerDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(IConnectionMultiplexer)); + Assert.NotNull(connectionMultiplexerDescriptor); + + var redisCacheServiceDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(ICacheService)); + Assert.NotNull(redisCacheServiceDescriptor); + Assert.Equal(typeof(RedisCacheService), redisCacheServiceDescriptor.ImplementationType); + } + + [Fact] + public void AddHybridCaching_ShouldRegisterBothMemoryAndRedisCacheServices_WithConnectionStringsRedis() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Memory:DefaultDurationInSeconds"] = "60", + ["ConnectionStrings:Redis"] = "localhost:6379", + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "300" + }) + .Build(); + + // Act + services.AddHybridCaching(configuration); + + // Assert - Verify services are registered without triggering actual Redis connection + var serviceProvider = services.BuildServiceProvider(); + var serviceDescriptors = services.ToList(); + + // Verify memory cache service can be resolved (no Redis connection needed) + var memoryCacheService = serviceProvider.GetService>(); + Assert.NotNull(memoryCacheService); + Assert.IsType(memoryCacheService); + + // Verify Redis services are registered (but don't resolve to avoid connection) + var connectionMultiplexerDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(IConnectionMultiplexer)); + Assert.NotNull(connectionMultiplexerDescriptor); + + var redisCacheServiceDescriptor = serviceDescriptors + .FirstOrDefault(d => d.ServiceType == typeof(ICacheService)); + Assert.NotNull(redisCacheServiceDescriptor); + Assert.Equal(typeof(RedisCacheService), redisCacheServiceDescriptor.ImplementationType); + } + + [Fact] + public void AddServiceCaching_ShouldConfigureCacheSettings() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Memory:DefaultDurationInSeconds"] = "120", + ["CacheSettings:Memory:Durations:TestMethod"] = "240" + }) + .Build(); + + // Act + services.AddServiceCaching(configuration); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var cacheSettings = serviceProvider.GetService>(); + Assert.NotNull(cacheSettings); + Assert.Equal(120, cacheSettings.Value.Memory.DefaultDurationInSeconds); + Assert.Equal(240, cacheSettings.Value.Memory.Durations["TestMethod"]); + } + + [Fact] + public void AddRedisCaching_ShouldConfigureRedisCacheSettings() + { + // Arrange + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CacheSettings:Redis:ConnectionString"] = "localhost:6379", + ["CacheSettings:Redis:DefaultDurationInSeconds"] = "600", + ["CacheSettings:Redis:KeyPrefix"] = "MyApp:", + ["CacheSettings:Redis:Database"] = "1", + ["CacheSettings:Redis:Durations:TestMethod"] = "1200" + }) + .Build(); + + // Act + services.AddRedisCaching(configuration); + var serviceProvider = services.BuildServiceProvider(); + + // Assert + var cacheSettings = serviceProvider.GetService>(); + Assert.NotNull(cacheSettings); + Assert.Equal("localhost:6379", cacheSettings.Value.Redis.ConnectionString); + Assert.Equal(600, cacheSettings.Value.Redis.DefaultDurationInSeconds); + Assert.Equal("MyApp:", cacheSettings.Value.Redis.KeyPrefix); + Assert.Equal(1, cacheSettings.Value.Redis.Database); + Assert.Equal(1200, cacheSettings.Value.Redis.Durations["TestMethod"]); + } + } +} \ No newline at end of file diff --git a/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Helpers/CacheKeyHelperTests.cs b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Helpers/CacheKeyHelperTests.cs index 1f4861b7..f68864d5 100644 --- a/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Helpers/CacheKeyHelperTests.cs +++ b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Helpers/CacheKeyHelperTests.cs @@ -1,3 +1,4 @@ +using System.Text; using GovUK.Dfe.CoreLibs.Caching.Helpers; namespace GovUK.Dfe.CoreLibs.Caching.Tests.Helpers @@ -62,5 +63,230 @@ public void GenerateHashedCacheKey_ForCollection_ShouldReturnConsistentHash_When // Assert Assert.Equal(result1, result2); } + + [Fact] + public void ComputeSha256_ShouldThrowArgumentNullException_WhenStreamIsNull() + { + // Arrange, Act & Assert + Assert.Throws(() => CacheKeyHelper.ComputeSha256(null!)); + } + + [Fact] + public void ComputeSha256_ShouldThrowArgumentException_WhenStreamIsNotReadable() + { + // Arrange + using var stream = new NonReadableStream(); + + // Act & Assert + var exception = Assert.Throws(() => CacheKeyHelper.ComputeSha256(stream)); + Assert.Contains("readable", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ComputeSha256_ShouldReturnUppercaseHexString_WhenGivenValidStream() + { + // Arrange + var content = "test-content"u8.ToArray(); + using var stream = new MemoryStream(content); + + // Act + var result = CacheKeyHelper.ComputeSha256(stream); + + // Assert + Assert.NotNull(result); + Assert.Equal(64, result.Length); // SHA-256 produces 32 bytes = 64 hex characters + Assert.Matches("^[A-F0-9]+$", result); // Should be uppercase hexadecimal + } + + [Fact] + public void ComputeSha256_ShouldReturnConsistentHash_WhenGivenSameContent() + { + // Arrange + var content = "test-content"u8.ToArray(); + using var stream1 = new MemoryStream(content); + using var stream2 = new MemoryStream(content); + + // Act + var result1 = CacheKeyHelper.ComputeSha256(stream1); + var result2 = CacheKeyHelper.ComputeSha256(stream2); + + // Assert + Assert.Equal(result1, result2); + } + + [Fact] + public void ComputeSha256_ShouldReturnDifferentHashes_ForDifferentContent() + { + // Arrange + var content1 = "content-1"u8.ToArray(); + var content2 = "content-2"u8.ToArray(); + using var stream1 = new MemoryStream(content1); + using var stream2 = new MemoryStream(content2); + + // Act + var result1 = CacheKeyHelper.ComputeSha256(stream1); + var result2 = CacheKeyHelper.ComputeSha256(stream2); + + // Assert + Assert.NotEqual(result1, result2); + } + + [Fact] + public void ComputeSha256_ShouldResetStreamPosition_WhenStreamIsSeekable() + { + // Arrange + var content = "test-content"u8.ToArray(); + using var stream = new MemoryStream(content); + stream.Position = 5; // Set position to middle of stream + var originalPosition = stream.Position; + + // Act + var result = CacheKeyHelper.ComputeSha256(stream); + + // Assert + Assert.Equal(originalPosition, stream.Position); // Position should be restored + Assert.NotNull(result); + } + + [Fact] + public void ComputeSha256_ShouldHandleEmptyStream() + { + // Arrange + using var stream = new MemoryStream(); + + // Act + var result = CacheKeyHelper.ComputeSha256(stream); + + // Assert + Assert.NotNull(result); + Assert.Equal(64, result.Length); + // SHA-256 of empty input is a known value + Assert.Equal("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855", result); + } + + [Fact] + public void ComputeSha256_ShouldHandleLargeStream() + { + // Arrange + var largeContent = new byte[1024 * 1024]; // 1 MB + for (int i = 0; i < largeContent.Length; i++) + { + largeContent[i] = (byte)(i % 256); + } + using var stream = new MemoryStream(largeContent); + + // Act + var result = CacheKeyHelper.ComputeSha256(stream); + + // Assert + Assert.NotNull(result); + Assert.Equal(64, result.Length); + Assert.Matches("^[A-F0-9]+$", result); + } + + [Fact] + public void ComputeSha256_ShouldComputeHashFromStreamStart_WhenStreamPositionIsNotAtBeginning() + { + // Arrange + var content = "test-content"u8.ToArray(); + using var stream = new MemoryStream(content); + stream.Position = 5; // Position in the middle + + // Act + var result = CacheKeyHelper.ComputeSha256(stream); + + // Assert - should compute hash of entire content, not from position 5 + using var fullStream = new MemoryStream(content); + var expectedHash = CacheKeyHelper.ComputeSha256(fullStream); + Assert.Equal(expectedHash, result); + } + + [Fact] + public void ComputeSha256_ShouldHandleNonSeekableStream() + { + // Arrange + var content = "test-content"u8.ToArray(); + using var readOnlyStream = new NonSeekableStream(content); + + // Act + var result = CacheKeyHelper.ComputeSha256(readOnlyStream); + + // Assert + Assert.NotNull(result); + Assert.Equal(64, result.Length); + Assert.Matches("^[A-F0-9]+$", result); + } + + [Fact] + public void ComputeSha256_ShouldProduceKnownHashForKnownInput() + { + // Arrange + var content = Encoding.UTF8.GetBytes("hello world"); + using var stream = new MemoryStream(content); + + // Act + var result = CacheKeyHelper.ComputeSha256(stream); + + // Assert + // Known SHA-256 hash of "hello world" + Assert.Equal("B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9", result); + } + + // Helper class to simulate a non-readable stream + private class NonReadableStream : Stream + { + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + // Helper class to simulate a non-seekable but readable stream + private class NonSeekableStream : Stream + { + private readonly byte[] _data; + private int _position; + + public NonSeekableStream(byte[] data) + { + _data = data; + _position = 0; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _data.Length; + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + int bytesToRead = Math.Min(count, _data.Length - _position); + if (bytesToRead <= 0) return 0; + + Array.Copy(_data, _position, buffer, offset, bytesToRead); + _position += bytesToRead; + return bytesToRead; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } } } diff --git a/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Services/MemoryCacheServiceTests.cs b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Services/MemoryCacheServiceTests.cs index d85fd433..39a1fa03 100644 --- a/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Services/MemoryCacheServiceTests.cs +++ b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Services/MemoryCacheServiceTests.cs @@ -84,6 +84,54 @@ public async Task GetOrAddAsync_ShouldFetchAndCacheValue_WhenCacheKeyDoesNotExis Arg.Any>()!); } + [Theory] + [CustomAutoData()] + public async Task GetAsync_ShouldReturnCachedValue_WhenCacheKeyExists(string cacheKey, string methodName, string expectedValue) + { + // Arrange + _memoryCache.TryGetValue(cacheKey, out Arg.Any()) + .Returns(x => + { + x[1] = expectedValue; + return true; + }); + + // Act + var result = await _cacheService.GetAsync(cacheKey); + + // Assert + Assert.Equal(expectedValue, result); + _memoryCache.Received(1).TryGetValue(cacheKey, out Arg.Any()); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Cache hit for key: {cacheKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetAsync_ShouldReturnDefault_WhenCacheKeyDoesNotExist(string cacheKey, string methodName) + { + // Arrange + _memoryCache.TryGetValue(cacheKey, out Arg.Any()) + .Returns(false); + + // Act + var result = await _cacheService.GetAsync(cacheKey); + + // Assert + Assert.Null(result); + _memoryCache.Received(1).TryGetValue(cacheKey, out Arg.Any()); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Cache miss for key: {cacheKey}")), + Arg.Any(), + Arg.Any>()!); + } + [Theory] [CustomAutoData()] public void Remove_ShouldRemoveValueFromCache_WhenCalled(string cacheKey) @@ -100,5 +148,90 @@ public void Remove_ShouldRemoveValueFromCache_WhenCalled(string cacheKey) Arg.Any(), Arg.Any>()!); } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldRespectCancellationToken_WhenCancelled(string cacheKey, string methodName) + { + // Arrange + var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + + _memoryCache.TryGetValue(cacheKey, out Arg.Any()).Returns(false); + + // Act & Assert + // TaskCanceledException is a subclass of OperationCanceledException + await Assert.ThrowsAnyAsync(async () => + await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult("value"), methodName, cts.Token)); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldNotThrowIfCancelled_WhenValueAlreadyCached(string cacheKey, string methodName) + { + // Arrange + var cachedValue = _fixture.Create(); + var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + + _memoryCache.TryGetValue(cacheKey, out Arg.Any()!).Returns(x => + { + x[1] = cachedValue; + return true; + }); + + // Act - should not throw because value is already in cache + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(cachedValue), methodName, cts.Token); + + // Assert + Assert.Equal(cachedValue, result); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldUseCustomDuration_WhenMethodSpecificDurationExists(string cacheKey) + { + // Arrange + var expectedValue = _fixture.Create(); + var methodName = "TestMethod"; + var expectedDuration = TimeSpan.FromSeconds(_cacheSettings.Durations[methodName]); + + _memoryCache.TryGetValue(cacheKey, out Arg.Any()).Returns(false); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + _memoryCache.Received(1).Set(cacheKey, expectedValue, expectedDuration); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldNotCache_WhenResultIsDefaultValue(string cacheKey, string methodName) + { + // Arrange + _memoryCache.TryGetValue(cacheKey, out Arg.Any()).Returns(false); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(null!), methodName); + + // Assert + Assert.Null(result); + + // Verify Set was not called by checking if TryGetValue was the only call + var setCalls = _memoryCache.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Set"); + Assert.Equal(0, setCalls); + } + + [Fact] + public void CacheType_ShouldReturnCorrectType() + { + // Act + var cacheType = _cacheService.CacheType; + + // Assert + Assert.Equal(typeof(Interfaces.IMemoryCacheType), cacheType); + } } -} +} \ No newline at end of file diff --git a/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Services/RedisCacheServiceTests.cs b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Services/RedisCacheServiceTests.cs new file mode 100644 index 00000000..9856dba3 --- /dev/null +++ b/src/Tests/GovUK.Dfe.CoreLibs.Caching.Tests/Services/RedisCacheServiceTests.cs @@ -0,0 +1,793 @@ +using System.Net; +using System.Text.Json; +using AutoFixture; +using GovUK.Dfe.CoreLibs.Caching.Services; +using GovUK.Dfe.CoreLibs.Caching.Settings; +using GovUK.Dfe.CoreLibs.Testing.AutoFixture.Attributes; +using GraphQL.Types; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using StackExchange.Redis; + +namespace GovUK.Dfe.CoreLibs.Caching.Tests.Services +{ + public class RedisCacheServiceTests + { + private readonly IFixture _fixture; + private readonly IConnectionMultiplexer _connectionMultiplexer; + private readonly IDatabase _database; + private readonly ILogger _logger; + private readonly IOptions _options; + private readonly RedisCacheService _cacheService; + private readonly RedisCacheSettings _redisCacheSettings; + private readonly JsonSerializerOptions _jsonSerializerOptions; + + public RedisCacheServiceTests() + { + _fixture = new Fixture(); + _connectionMultiplexer = Substitute.For(); + _database = Substitute.For(); + _logger = Substitute.For>(); + + _redisCacheSettings = new RedisCacheSettings + { + DefaultDurationInSeconds = 300, + Durations = new Dictionary { { "TestMethod", 600 } }, + KeyPrefix = "Test:", + Database = 0 + }; + var settings = new CacheSettings { Redis = _redisCacheSettings }; + _options = Options.Create(settings); + + _jsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + _connectionMultiplexer.GetDatabase(_redisCacheSettings.Database).Returns(_database); + + _cacheService = new RedisCacheService(_connectionMultiplexer, _logger, _options); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldReturnCachedValue_WhenCacheKeyExists(string cacheKey, string methodName) + { + // Arrange + var cachedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var serializedValue = JsonSerializer.Serialize(cachedValue); + + _database.StringGetAsync(fullKey).Returns(new RedisValue(serializedValue)); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(cachedValue), methodName); + + // Assert + Assert.Equal(cachedValue, result); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache hit for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldFetchAndCacheValue_WhenCacheKeyDoesNotExist(string cacheKey, string methodName) + { + // Arrange + var expectedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + + // First call returns null (cache miss), second call after lock also returns null + _database.StringGetAsync(fullKey).Returns(RedisValue.Null, RedisValue.Null); + + // Lock acquisition succeeds + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(true); + + // Cache set succeeds + _database.StringSetAsync(fullKey, Arg.Any(), TimeSpan.FromSeconds(_redisCacheSettings.DefaultDurationInSeconds)) + .Returns(Task.FromResult(true)); + + // Mock Lua script execution for lock release + _database.ScriptEvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(RedisResult.Create(1)); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + await _database.Received(1).StringSetAsync( + fullKey, + Arg.Any(), + TimeSpan.FromSeconds(_redisCacheSettings.DefaultDurationInSeconds)); + + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache miss for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cached result for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldUseCustomDuration_WhenMethodSpecificDurationExists(string cacheKey) + { + // Arrange + var expectedValue = _fixture.Create(); + var methodName = "TestMethod"; + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + var expectedDuration = TimeSpan.FromSeconds(_redisCacheSettings.Durations[methodName]); + + _database.StringGetAsync(fullKey).Returns(RedisValue.Null, RedisValue.Null); + + // Lock acquisition succeeds + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(true); + + _database.StringSetAsync(fullKey, Arg.Any(), expectedDuration) + .Returns(Task.FromResult(true)); + + // Mock Lua script execution for lock release + _database.ScriptEvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(RedisResult.Create(1)); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + await _database.Received(1).StringSetAsync(fullKey, Arg.Any(), expectedDuration); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldFallbackToFetchFunction_WhenRedisExceptionOccurs(string cacheKey, string methodName) + { + // Arrange + var expectedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + + _database.StringGetAsync(fullKey).Returns(Task.FromException(new RedisException("Redis connection failed"))); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis error occurred for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldRemoveInvalidCacheAndFetch_WhenJsonExceptionOccurs(string cacheKey, string methodName) + { + // Arrange + var expectedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var invalidJson = "invalid-json-data"; + + _database.StringGetAsync(fullKey).Returns(new RedisValue(invalidJson)); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + await _database.Received(1).KeyDeleteAsync(fullKey); + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"JSON serialization error for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldNotCache_WhenResultIsDefaultValue(string cacheKey, string methodName) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + + _database.StringGetAsync(fullKey).Returns(RedisValue.Null, RedisValue.Null); + + // Lock acquisition succeeds + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(true); + + // Mock Lua script execution for lock release + _database.ScriptEvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(RedisResult.Create(1)); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(null!), methodName); + + // Assert + Assert.Null(result); + // Should only set the lock, not the actual cache value + await _database.Received(1).StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists); + await _database.DidNotReceive().StringSetAsync( + Arg.Is(k => k.ToString() == fullKey), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [CustomAutoData()] + public void Remove_ShouldRemoveValueFromCache_WhenCalled(string cacheKey) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + + // Act + _cacheService.Remove(cacheKey); + + // Assert + _database.Received(1).KeyDelete(fullKey); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache removed for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task RemoveAsync_ShouldRemoveValueFromCache_WhenCalled(string cacheKey) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + + // Act + await _cacheService.RemoveAsync(cacheKey); + + // Assert + await _database.Received(1).KeyDeleteAsync(fullKey); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache removed for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public void Remove_ShouldLogError_WhenRedisExceptionOccurs(string cacheKey) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + _database.When(x => x.KeyDelete(fullKey)).Do(x => throw new RedisException("Redis connection failed")); + + // Act + _cacheService.Remove(cacheKey); + + // Assert + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis error occurred while removing key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task RemoveByPatternAsync_ShouldRemoveMatchingKeys_WhenCalled(string pattern) + { + // Arrange + var fullPattern = $"{_redisCacheSettings.KeyPrefix}{pattern}"; + var keys = new RedisKey[] { "key1", "key2", "key3" }; + var server = Substitute.For(); + var endpoints = new EndPoint[] { new DnsEndPoint("localhost", 6379) }; + + _connectionMultiplexer.GetEndPoints().Returns(args => endpoints); + _connectionMultiplexer.GetServer(endpoints[0]).Returns(server); + server.Keys(database: _redisCacheSettings.Database, pattern: fullPattern).Returns(keys); + + // Act + await _cacheService.RemoveByPatternAsync(pattern); + + // Assert + foreach (var key in keys) + { + await _database.Received(1).KeyDeleteAsync(key); + } + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache entries removed for pattern: {fullPattern}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetAsync_ShouldReturnCachedValue_WhenCacheKeyExists(string cacheKey, string methodName, string expectedValue) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var serializedValue = JsonSerializer.Serialize(expectedValue, _jsonSerializerOptions); + _database.StringGetAsync(fullKey).Returns(new RedisValue(serializedValue)); + + // Act + var result = await _cacheService.GetAsync(cacheKey); + + // Assert + Assert.Equal(expectedValue, result); + await _database.Received(1).StringGetAsync(fullKey); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache hit for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetAsync_ShouldReturnDefault_WhenCacheKeyDoesNotExist(string cacheKey, string methodName) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + _database.StringGetAsync(fullKey).Returns(RedisValue.Null); + + // Act + var result = await _cacheService.GetAsync(cacheKey); + + // Assert + Assert.Null(result); + await _database.Received(1).StringGetAsync(fullKey); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache miss for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetAsync_ShouldReturnDefault_WhenRedisExceptionOccurs(string cacheKey, string methodName) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + _database.StringGetAsync(fullKey).Returns(Task.FromException(new RedisException("Redis connection failed"))); + + // Act + var result = await _cacheService.GetAsync(cacheKey); + + // Assert + Assert.Null(result); + await _database.Received(1).StringGetAsync(fullKey); + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis error occurred for key: {fullKey}. Returning default value")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetAsync_ShouldReturnDefault_WhenJsonExceptionOccurs(string cacheKey, string methodName) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var invalidJson = "invalid json"; + _database.StringGetAsync(fullKey).Returns(new RedisValue(invalidJson)); + + // Act + var result = await _cacheService.GetAsync(cacheKey); + + // Assert + Assert.Null(result); + await _database.Received(1).StringGetAsync(fullKey); + await _database.Received(1).KeyDeleteAsync(fullKey); + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"JSON deserialization error for key: {fullKey}. Removing invalid cache entry")), + Arg.Any(), + Arg.Any>()!); + } + + [Fact] + public void CacheType_ShouldReturnCorrectType() + { + // Act + var cacheType = _cacheService.CacheType; + + // Assert + Assert.Equal(typeof(Interfaces.IRedisCacheType), cacheType); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldAcquireLockAndCache_WhenCacheMissOccurs(string cacheKey, string methodName) + { + // Arrange + var expectedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + + _database.StringGetAsync(fullKey).Returns(RedisValue.Null, RedisValue.Null); + + // Lock acquisition succeeds + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(true); + + _database.StringSetAsync(fullKey, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(true)); + + // Mock Lua script execution for lock release (return 1 = success) + _database.ScriptEvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(RedisResult.Create(1)); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + + // Verify lock was acquired + await _database.Received(1).StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists); + + // Verify lock was released using Lua script + await _database.Received(1).ScriptEvaluateAsync( + Arg.Any(), + Arg.Is(keys => keys.Length == 1 && keys[0].ToString() == lockKey), + Arg.Any()); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldReturnCachedValue_WhenAnotherThreadPopulatesCacheWhileWaitingForLock(string cacheKey, string methodName) + { + // Arrange + var cachedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + var serializedValue = JsonSerializer.Serialize(cachedValue); + + // First check: cache miss, second check after lock: cache hit + _database.StringGetAsync(fullKey).Returns( + RedisValue.Null, + new RedisValue(serializedValue)); + + // Lock acquisition succeeds + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(true); + + // Mock Lua script execution for lock release + _database.ScriptEvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(RedisResult.Create(1)); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult("should-not-be-called"), methodName); + + // Assert + Assert.Equal(cachedValue, result); + + // Should not call StringSetAsync for the actual cache key since value was populated + await _database.DidNotReceive().StringSetAsync( + Arg.Is(k => k.ToString() == fullKey), + Arg.Any(), + Arg.Any()); + + // Verify double-check pattern logged + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache hit after lock acquisition for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldWaitAndRetry_WhenLockAcquisitionFails(string cacheKey, string methodName) + { + // Arrange + var cachedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + var serializedValue = JsonSerializer.Serialize(cachedValue); + + // First check: cache miss + // Subsequent checks: null for first 2 retries, then cache hit on 3rd retry + _database.StringGetAsync(fullKey).Returns( + RedisValue.Null, // Initial check + RedisValue.Null, // 1st retry + RedisValue.Null, // 2nd retry + new RedisValue(serializedValue)); // 3rd retry - cache hit + + // Lock acquisition fails (another thread has the lock) + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(false); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult("should-not-be-called"), methodName); + + // Assert + Assert.Equal(cachedValue, result); + + // Verify it retried reading from cache multiple times + await _database.Received(4).StringGetAsync(fullKey); + + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Waiting for another thread to populate cache for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Redis cache hit after waiting for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldFallbackToFetchFunction_WhenMaxRetriesReachedAndCacheStillEmpty(string cacheKey, string methodName) + { + // Arrange + var expectedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + + // Always return null (cache never gets populated) + _database.StringGetAsync(fullKey).Returns(RedisValue.Null); + + // Lock acquisition fails + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(false); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Max retries reached waiting for cache population for key: {fullKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldReleaseLock_WhenExceptionOccursDuringFetch(string cacheKey, string methodName) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + + _database.StringGetAsync(fullKey).Returns(RedisValue.Null, RedisValue.Null); + + // Lock acquisition succeeds + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(true); + + // Mock Lua script execution for lock release + _database.ScriptEvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(RedisResult.Create(1)); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromException(new InvalidOperationException("Fetch failed")), methodName)); + + // Verify lock was still released even though exception occurred + await _database.Received(1).ScriptEvaluateAsync( + Arg.Any(), + Arg.Is(keys => keys.Length == 1 && keys[0].ToString() == lockKey), + Arg.Any()); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldRespectCancellationToken_WhenCancelled(string cacheKey, string methodName) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + + _database.StringGetAsync(fullKey).Returns(RedisValue.Null); + + // Act & Assert + // TaskCanceledException is a subclass of OperationCanceledException + await Assert.ThrowsAnyAsync(async () => + await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult("value"), methodName, cts.Token)); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldCancelDuringWait_WhenCancellationTokenTriggered(string cacheKey, string methodName) + { + // Arrange + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + var cts = new CancellationTokenSource(); + + _database.StringGetAsync(fullKey).Returns(RedisValue.Null); + + // Lock acquisition fails (simulating waiting scenario) + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(false); + + // Cancel after a short delay + _ = Task.Run(async () => + { + await Task.Delay(100); + cts.Cancel(); + }); + + // Act & Assert + // TaskCanceledException is a subclass of OperationCanceledException + var exception = await Assert.ThrowsAnyAsync(async () => + await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult("value"), methodName, cts.Token)); + + Assert.NotNull(exception); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldHandleConcurrentRequests_WithoutDuplicateFetches(string cacheKey, string methodName) + { + // Arrange + var expectedValue = _fixture.Create(); + var fullKey = $"{_redisCacheSettings.KeyPrefix}{cacheKey}"; + var lockKey = $"{fullKey}:lock"; + var fetchCallCount = 0; + var serializedValue = JsonSerializer.Serialize(expectedValue, _jsonSerializerOptions); + + // Setup: First request gets cache miss, then after it populates, subsequent requests get cache hit + var cacheGetCallCount = 0; + _database.StringGetAsync(fullKey).Returns(callInfo => + { + var count = Interlocked.Increment(ref cacheGetCallCount); + // First few calls return null (cache miss), then return the cached value + return count <= 2 ? RedisValue.Null : new RedisValue(serializedValue); + }); + + // First thread acquires lock, others fail + var lockCallCount = 0; + _database.StringSetAsync( + Arg.Is(k => k.ToString() == lockKey), + Arg.Any(), + Arg.Any(), + When.NotExists) + .Returns(callInfo => + { + var acquired = Interlocked.Increment(ref lockCallCount) == 1; + return acquired; + }); + + // Cache set succeeds - this simulates the first thread populating the cache + _database.StringSetAsync(fullKey, Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + // After cache is set, subsequent StringGetAsync calls should return the value + return Task.FromResult(true); + }); + + // Mock Lua script execution for lock release + _database.ScriptEvaluateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(RedisResult.Create(1)); + + Func> fetchFunction = async () => + { + Interlocked.Increment(ref fetchCallCount); + await Task.Delay(10); // Small delay to simulate work + return expectedValue; + }; + + // Act - simulate multiple concurrent requests + var tasks = Enumerable.Range(0, 3).Select(_ => + _cacheService.GetOrAddAsync(cacheKey, fetchFunction, methodName) + ).ToArray(); + + var results = await Task.WhenAll(tasks); + + // Assert + // Only one fetch should have occurred (the one that got the lock) + Assert.Equal(1, fetchCallCount); + Assert.All(results, r => Assert.Equal(expectedValue, r)); + } + } +} \ No newline at end of file