using System.Reflection;
using adas_core.Application.Services.Caching;
using adas_core.Domain.Models.AppSettings;
using Microsoft.Extensions.Logging;
using Moq;
using Newtonsoft.Json;
using StackExchange.Redis;
using Options = Microsoft.Extensions.Options.Options;
namespace adas_core.Test.Services;
///
/// Represents an NUnit containing unit tests that exercise the behavior of the Redis service.
///
///
[TestFixture]
public class RedisServiceTest
{
private record TestModel(string Name);
private Mock _mockDb = null!;
private LockManagerService _lockMgr = null!;
///
/// Initializes test dependencies before each test execution by creating a mock instance and instantiating the with a mocked logger and an in-memory lock provider.
///
///
[SetUp]
public void SetUp()
{
_mockDb = new Mock();
_lockMgr = new LockManagerService(
new Mock>().Object,
new InMemoryLockProvider());
}
///
/// Creates a configured instance of for testing, allowing
/// optional customization of cache settings and the simulated availability of the Redis backend.
///
/// Optional cache settings to apply; when null, a new default instance is used.
/// Flag indicating whether the Redis service should be marked as available; defaults to true.
/// A instance with the database and availability state initialized for testing.
///
private RedisService CreateSut(CacheSettings? settings = null, bool redisAvailable = true)
{
var sut = new RedisService(
Options.Create(settings ?? new CacheSettings()),
new Mock>().Object,
_lockMgr);
SetField(sut, "_database", _mockDb.Object);
SetField(sut, "_isRedisAvailable", redisAvailable);
return sut;
}
///
/// Sets the value of a non-public instance field on a object using reflection.
///
/// The instance whose field will be set.
/// The name of the non-public instance field to assign.
/// The value to assign to the field. Can be null.
///
private static void SetField(object target, string name, object? value)
=> typeof(RedisService)
.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(target, value);
///
/// Configures the mock database to return a successful result (true) for any invocation of StringSetAsync, regardless of the supplied key, value, expiry, overwrite flag, condition, or command flags.
///
///
private void SetupStringSetAsync()
=> _mockDb
.Setup(db => db.StringSetAsync(
It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(true);
///
/// Configures the mock Redis database to handle KeyExpire calls by returning true for any combination of key, expiration, condition, and command flag arguments.
///
///
private void SetupKeyExpire()
=> _mockDb
.Setup(db => db.KeyExpire(
It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny()))
.Returns(true);
#region TC-38
///
/// Verifies that GetOrSetObjectAsync invokes the supplied factory when Redis is unavailable, returning the factory's result without attempting any Redis read or write operations.
///
///
[Test]
public async Task GetOrSetObjectAsync_ExecutesFactory_WhenRedisUnavailable()
{
var sut = CreateSut(redisAvailable: false);
var factoryInvoked = false;
var result = await sut.GetOrSetObjectAsync(
"patients:latestObs:abc",
() => { factoryInvoked = true; return Task.FromResult("result"); });
Assert.That(factoryInvoked, Is.True);
Assert.That(result, Is.EqualTo("result"));
_mockDb.Verify(db => db.StringGetAsync(It.IsAny(), It.IsAny()), Times.Never);
_mockDb.Verify(db => db.StringSetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
#endregion
#region TC-39
///
/// Verifies that GetOrSetObjectAsync returns the cached object deserialized from Redis
/// when a value is present in the cache, without invoking the factory delegate and without
/// attempting to write back to Redis.
///
///
[Test]
public async Task GetOrSetObjectAsync_ReturnsCachedObject_WhenRedisHit()
{
var cached = new TestModel("cached");
_mockDb.Setup(db => db.StringGetAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync((RedisValue)JsonConvert.SerializeObject(cached));
SetupKeyExpire();
var sut = CreateSut();
var factoryInvoked = false;
var result = await sut.GetOrSetObjectAsync(
"patients:latestObs:abc",
() => { factoryInvoked = true; return Task.FromResult(new TestModel("fresh")); });
Assert.That(factoryInvoked, Is.False);
Assert.That(result?.Name, Is.EqualTo("cached"));
_mockDb.Verify(db => db.StringSetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
#endregion
#region TC-40
///
/// Verifies that GetObjectAsync calls the underlying Redis KeyExpire command with the configured TTL
/// when the updateExpiration flag is set to true, ensuring cache entries are refreshed upon a successful hit.
///
///
[Test]
public async Task GetObjectAsync_CallsKeyExpire_WhenUpdateExpirationIsTrue()
{
var settings = new CacheSettings
{
Redis = new RedisSettings { Ttl = new TtlSettings { PatientsSeconds = 300 } }
};
_mockDb.Setup(db => db.StringGetAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync((RedisValue)JsonConvert.SerializeObject(new TestModel("hit")));
SetupKeyExpire();
var sut = CreateSut(settings);
await sut.GetObjectAsync("patients:latestObs:abc", updateExpiration: true);
_mockDb.Verify(
db => db.KeyExpire(
It.IsAny(),
It.Is(t => t == TimeSpan.FromSeconds(300)),
It.IsAny(),
It.IsAny()),
Times.Once);
}
///
/// Verifies that GetObjectAsync does not invoke the Redis key expiration command
/// when the caller explicitly requests that the existing expiration be left unchanged.
///
///
[Test]
public async Task GetObjectAsync_DoesNotCallKeyExpire_WhenUpdateExpirationIsFalse()
{
_mockDb.Setup(db => db.StringGetAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync((RedisValue)JsonConvert.SerializeObject(new TestModel("hit")));
var sut = CreateSut();
await sut.GetObjectAsync("patients:latestObs:abc", updateExpiration: false);
_mockDb.Verify(
db => db.KeyExpire(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
#endregion
#region TC-41
///
/// Verifies that GetOrSetObjectAsync invokes the supplied factory and persists the resulting object
/// to Redis with the configured TTL when the cache lookup returns a miss (i.e., the stored value is null).
/// Ensures the factory delegate is executed, the deserialized value matches the factory output, and the
/// object is written to cache with the expected expiration.
///
///
[Test]
public async Task GetOrSetObjectAsync_InvokesFactoryAndPersists_WhenCacheMiss()
{
var settings = new CacheSettings
{
Redis = new RedisSettings { Ttl = new TtlSettings { PatientsSeconds = 300, PatientObservationsSeconds = 300} }
};
_mockDb.Setup(db => db.StringGetAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(RedisValue.Null);
SetupStringSetAsync();
var sut = CreateSut(settings);
var expected = new TestModel("fresh");
var factoryInvoked = false;
var result = await sut.GetOrSetObjectAsync(
"patients:latestObs:abc",
() => { factoryInvoked = true; return Task.FromResult(expected); });
var expectedJson = JsonConvert.SerializeObject(expected);
Assert.That(factoryInvoked, Is.True);
Assert.That(result?.Name, Is.EqualTo("fresh"));
_mockDb.Verify(
db => db.StringSetAsync(
It.IsAny(),
It.IsAny(),
It.Is(t => t == TimeSpan.FromSeconds(300)),
It.IsAny(),
It.IsAny(),
It.IsAny()),
Times.Once);
It.Is(v => v.Equals(expectedJson));
}
#endregion
#region TC-42
///
/// Verifies that when the cache lookup returns no value and the factory delegate produces null,
/// the method returns null and does not persist any value to the underlying cache store.
///
///
[Test]
public async Task GetOrSetObjectAsync_DoesNotPersist_WhenFactoryReturnsNull()
{
_mockDb.Setup(db => db.StringGetAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(RedisValue.Null);
var sut = CreateSut();
var result = await sut.GetOrSetObjectAsync(
"patients:latestObs:abc",
() => Task.FromResult(null));
Assert.That(result, Is.Null);
_mockDb.Verify(
db => db.StringSetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
#endregion
#region TC-43
///
/// Verifies that GetObjectAsync successfully retrieves and deserializes the stored object while skipping the
/// key-expiration refresh when updateExpiration is set to false, ensuring KeyExpire is never invoked.
///
///
[Test]
public async Task GetObjectAsync_ReturnsObject_AndSkipsKeyExpire_WhenUpdateExpirationFalse()
{
var expected = new TestModel("data");
_mockDb.Setup(db => db.StringGetAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync((RedisValue)JsonConvert.SerializeObject(expected));
var sut = CreateSut();
var result = await sut.GetObjectAsync("patients:latestObs:abc", updateExpiration: false);
Assert.That(result?.Name, Is.EqualTo("data"));
_mockDb.Verify(
db => db.KeyExpire(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
#endregion
#region TC-44
///
/// Verifies that SetObjectAsync serializes the supplied object to JSON and persists it in Redis
/// with the entity-specific TTL (600 seconds) configured for the "Patients" entity in .
///
///
[Test]
public async Task SetObjectAsync_SerializesToJson_AndPersistsWithEntityTtl()
{
var settings = new CacheSettings
{
Redis = new RedisSettings { Ttl = new TtlSettings { PatientsSeconds = 600 } }
};
SetupStringSetAsync();
var sut = CreateSut(settings);
var obj = new TestModel("save-me");
var expectedJson = JsonConvert.SerializeObject(obj);
await sut.SetObjectAsync("patients:latestObs:abc", obj, null, true);
_mockDb.Verify(
db => db.StringSetAsync(
It.IsAny(),
It.IsAny(),
It.Is(t => t == TimeSpan.FromSeconds(600)),
It.IsAny(),
It.IsAny(),
It.IsAny()),
Times.Once);
It.Is(v => v.Equals(expectedJson));
}
#endregion
}