using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// /// hit/miss/expiry/invalidation behavior, independent of the endpoint and subscriber wiring /// (covered separately by ). /// public class ApiMethodCacheTests { private static ApiMethod Method(string name) => new(name, "return 1;") { Id = 1 }; /// Verifies that a second call within the TTL is served from the cache without refetching. /// A task that represents the asynchronous operation. [Fact] public async Task GetOrFetchAsync_SecondCallWithinTtl_ServesFromCache_NoRefetch() { var cache = new ApiMethodCache(TimeSpan.FromMinutes(5)); var fetchCount = 0; Task Fetch(CancellationToken _) { fetchCount++; return Task.FromResult(Method("m")); } var first = await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None); var second = await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None); Assert.Equal(1, fetchCount); Assert.Same(first, second); } /// Verifies that a call after the TTL expires refetches the method. /// A task that represents the asynchronous operation. [Fact] public async Task GetOrFetchAsync_AfterTtlExpiry_Refetches() { var time = new FakeTimeProvider(DateTimeOffset.UtcNow); var cache = new ApiMethodCache(TimeSpan.FromSeconds(1), time); var fetchCount = 0; Task Fetch(CancellationToken _) { fetchCount++; return Task.FromResult(Method("m")); } await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None); time.Advance(TimeSpan.FromSeconds(2)); await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None); Assert.Equal(2, fetchCount); } /// Verifies that a negative result is cached until the TTL expires. /// A task that represents the asynchronous operation. [Fact] public async Task GetOrFetchAsync_NegativeResult_IsCachedUntilTtlExpires() { var cache = new ApiMethodCache(TimeSpan.FromMinutes(5)); var fetchCount = 0; Task Fetch(CancellationToken _) { fetchCount++; return Task.FromResult(null); } var first = await cache.GetOrFetchAsync("missing", Fetch, CancellationToken.None); var second = await cache.GetOrFetchAsync("missing", Fetch, CancellationToken.None); Assert.Null(first); Assert.Null(second); Assert.Equal(1, fetchCount); } /// Verifies that invalidating an entry makes the next call refetch it. /// A task that represents the asynchronous operation. [Fact] public async Task Invalidate_DropsEntry_NextCallRefetches() { var cache = new ApiMethodCache(TimeSpan.FromMinutes(5)); var fetchCount = 0; Task Fetch(CancellationToken _) { fetchCount++; return Task.FromResult(Method("m")); } await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None); cache.Invalidate("m"); await cache.GetOrFetchAsync("m", Fetch, CancellationToken.None); Assert.Equal(2, fetchCount); } /// Verifies that invalidating an unknown method name does not throw. [Fact] public void Invalidate_UnknownName_DoesNotThrow() { var cache = new ApiMethodCache(TimeSpan.FromMinutes(5)); cache.Invalidate("never-cached"); } /// Verifies that a non-positive TTL makes the constructor throw. [Fact] public void Constructor_NonPositiveTtl_Throws() { Assert.Throws(() => new ApiMethodCache(TimeSpan.Zero)); Assert.Throws(() => new ApiMethodCache(TimeSpan.FromSeconds(-1))); } /// Minimal controllable for TTL-expiry tests. private sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _now; /// Initializes the provider at the given start time. /// The initial UTC time. public FakeTimeProvider(DateTimeOffset start) => _now = start; /// Advances the current time. /// The amount of time to advance. public void Advance(TimeSpan by) => _now += by; /// public override DateTimeOffset GetUtcNow() => _now; } }