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