using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// WP2.6b (arch-review misc — Inbound API per-request SQL): /// 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 }; [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); } [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); } [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); } [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); } [Fact] public void Invalidate_UnknownName_DoesNotThrow() { var cache = new ApiMethodCache(TimeSpan.FromMinutes(5)); cache.Invalidate("never-cached"); } [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; public FakeTimeProvider(DateTimeOffset start) => _now = start; public void Advance(TimeSpan by) => _now += by; public override DateTimeOffset GetUtcNow() => _now; } }