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; [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 }