using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.InboundAPI; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// Gitea #85: an API-method create / update / delete through the management API must drop this /// node's entry once the change commits. Before, only the compiled /// handler was invalidated, so the node kept resolving the pre-change row for up to the cache /// TTL — a deleted method stayed callable and a cached not-found hid a newly created one. /// public class ApiMethodCacheInvalidationTests : TestKit { private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction(); private readonly IInboundApiRepository _apiRepo = Substitute.For(); // A TTL far longer than the test, so only an explicit invalidation can clear an entry. private readonly ApiMethodCache _cache = new(TimeSpan.FromHours(1)); /// Initializes the harness with a long-TTL method cache. public ApiMethodCacheInvalidationTests() { _services.AddScoped(_ => _apiRepo); _services.AddScoped(_ => Substitute.For()); _services.AddSingleton(_cache); _apiRepo.SaveChangesAsync(Arg.Any()).Returns(1); } private IActorRef CreateActor() => Sys.ActorOf(Props.Create(() => new ManagementActor( _services.BuildServiceProvider(), NullLogger.Instance))); private static ManagementEnvelope Envelope(object command) => new(new AuthenticatedUser("designer", "Designer", new[] { "Designer" }, Array.Empty()), command, Guid.NewGuid().ToString("N")); private Task Seed(string name, ApiMethod? row) => _cache.GetOrFetchAsync(name, _ => Task.FromResult(row), CancellationToken.None); /// Returns true when the cache re-fetches (the entry was dropped). private async Task Refetches(string name) { var fetched = false; await _cache.GetOrFetchAsync(name, _ => { fetched = true; return Task.FromResult(null); }, CancellationToken.None); return fetched; } /// A cached not-found must not hide a method created through the management API. /// A task that represents the asynchronous operation. [Fact] public async Task Create_DropsCachedNotFound() { await Seed("Fresh", null); Assert.False(await Refetches("Fresh")); // positive control: the negative entry is served CreateActor().Tell(Envelope(new CreateApiMethodCommand("Fresh", "return 1;", 30, null, null))); ExpectMsg(TimeSpan.FromSeconds(30)); Assert.True(await Refetches("Fresh")); } /// An update drops the cached pre-change row. /// A task that represents the asynchronous operation. [Fact] public async Task Update_DropsCachedRow() { var existing = new ApiMethod("Edited", "return 1;") { Id = 7, TimeoutSeconds = 10 }; _apiRepo.GetApiMethodByIdAsync(7, Arg.Any()).Returns(existing); await Seed("Edited", existing); CreateActor().Tell(Envelope(new UpdateApiMethodCommand(7, "return 2;", 10, null, null))); ExpectMsg(TimeSpan.FromSeconds(30)); Assert.True(await Refetches("Edited")); } /// A delete drops the cached row so the deleted method is no longer resolved. /// A task that represents the asynchronous operation. [Fact] public async Task Delete_DropsCachedRow() { var existing = new ApiMethod("Gone", "return 1;") { Id = 9, TimeoutSeconds = 10 }; _apiRepo.GetApiMethodByIdAsync(9, Arg.Any()).Returns(existing); await Seed("Gone", existing); CreateActor().Tell(Envelope(new DeleteApiMethodCommand(9))); ExpectMsg(TimeSpan.FromSeconds(30)); Assert.True(await Refetches("Gone")); } /// A change to one method leaves other methods' entries cached. /// A task that represents the asynchronous operation. [Fact] public async Task Delete_LeavesOtherMethodsCached() { var existing = new ApiMethod("Gone2", "return 1;") { Id = 10, TimeoutSeconds = 10 }; _apiRepo.GetApiMethodByIdAsync(10, Arg.Any()).Returns(existing); await Seed("Other", new ApiMethod("Other", "return 3;")); CreateActor().Tell(Envelope(new DeleteApiMethodCommand(10))); ExpectMsg(TimeSpan.FromSeconds(30)); Assert.False(await Refetches("Other")); } }