using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// /// Coverage for the notify-and-fetch staging store on /// : supersession (a newer deploy for the same /// instance replaces the prior pending row), multi-instance coexistence, TTL purge, and /// delete-by-id. Uses the shared SQLite in-memory fixture, which enforces the /// PendingDeployment → Instance FK, so each test seeds real Instance rows. /// public class PendingDeploymentRepositoryTests : IDisposable { private readonly ScadaBridgeDbContext _context; private readonly DeploymentManagerRepository _repository; public PendingDeploymentRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new DeploymentManagerRepository(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } /// /// Seeds an Instance (with its required Site + Template) and returns its generated id. /// SQLite EnsureCreated enforces the FK to Instances, so a real row is required. /// private async Task SeedInstanceAsync(string uniqueName) { var site = new Site($"Site-{uniqueName}", $"S-{uniqueName}"); var template = new Template($"T-{uniqueName}"); _context.Sites.Add(site); _context.Templates.Add(template); await _context.SaveChangesAsync(); var instance = new Instance(uniqueName) { SiteId = site.Id, TemplateId = template.Id }; _context.Instances.Add(instance); await _context.SaveChangesAsync(); return instance.Id; } private static PendingDeployment NewPending(string deploymentId, int instanceId, string config) { var now = DateTimeOffset.UtcNow; return new PendingDeployment( deploymentId, instanceId, revisionHash: "rev-" + deploymentId, configurationJson: config, token: "tok-" + deploymentId, createdAtUtc: now, expiresAtUtc: now.AddMinutes(10)); } [Fact] public async Task AddPendingDeployment_SecondDeployForSameInstance_SupersedesPriorRow() { var instanceId = await SeedInstanceAsync("Inst7"); await _repository.AddPendingDeploymentAsync(NewPending("dep1", instanceId, "{\"v\":1}")); await _repository.SaveChangesAsync(); await _repository.AddPendingDeploymentAsync(NewPending("dep2", instanceId, "{\"v\":2}")); await _repository.SaveChangesAsync(); // The prior pending row must be gone; only the newest deploy survives. Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep1")); var current = await _repository.GetPendingDeploymentByIdAsync("dep2"); Assert.NotNull(current); Assert.Equal("{\"v\":2}", current!.ConfigurationJson); Assert.Equal(instanceId, current.InstanceId); } [Fact] public async Task AddPendingDeployment_DifferentInstances_Coexist() { var instance1 = await SeedInstanceAsync("Inst1"); var instance2 = await SeedInstanceAsync("Inst2"); await _repository.AddPendingDeploymentAsync(NewPending("depA", instance1, "{\"a\":1}")); await _repository.AddPendingDeploymentAsync(NewPending("depB", instance2, "{\"b\":1}")); await _repository.SaveChangesAsync(); var a = await _repository.GetPendingDeploymentByIdAsync("depA"); var b = await _repository.GetPendingDeploymentByIdAsync("depB"); Assert.NotNull(a); Assert.NotNull(b); Assert.Equal(instance1, a!.InstanceId); Assert.Equal(instance2, b!.InstanceId); } [Fact] public async Task PurgeExpiredPendingDeployments_RemovesOnlyExpired_ReturnsCount() { var instance1 = await SeedInstanceAsync("Inst1"); var instance2 = await SeedInstanceAsync("Inst2"); var instance3 = await SeedInstanceAsync("Inst3"); var now = DateTimeOffset.UtcNow; // Two expired rows (ExpiresAtUtc <= now) and one live row (in the future). await _repository.AddPendingDeploymentAsync(new PendingDeployment( "expired-1", instance1, "rev", "{}", "tok", now.AddMinutes(-20), now.AddMinutes(-10))); await _repository.SaveChangesAsync(); await _repository.AddPendingDeploymentAsync(new PendingDeployment( "expired-2", instance2, "rev", "{}", "tok", now.AddMinutes(-20), now)); await _repository.SaveChangesAsync(); await _repository.AddPendingDeploymentAsync(new PendingDeployment( "live-1", instance3, "rev", "{}", "tok", now, now.AddMinutes(10))); await _repository.SaveChangesAsync(); var purged = await _repository.PurgeExpiredPendingDeploymentsAsync(now); Assert.Equal(2, purged); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("expired-1")); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("expired-2")); Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1")); } [Fact] public async Task PurgeExpiredPendingDeployments_NoneExpired_ReturnsZero() { var instanceId = await SeedInstanceAsync("Inst1"); var now = DateTimeOffset.UtcNow; await _repository.AddPendingDeploymentAsync(new PendingDeployment( "live-1", instanceId, "rev", "{}", "tok", now, now.AddMinutes(10))); await _repository.SaveChangesAsync(); var purged = await _repository.PurgeExpiredPendingDeploymentsAsync(now); Assert.Equal(0, purged); Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1")); } [Fact] public async Task DeletePendingDeploymentById_RemovesRow() { var instanceId = await SeedInstanceAsync("Inst1"); await _repository.AddPendingDeploymentAsync(NewPending("dep1", instanceId, "{}")); await _repository.SaveChangesAsync(); await _repository.DeletePendingDeploymentByIdAsync("dep1"); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep1")); } [Fact] public async Task DeletePendingDeploymentById_UnknownId_NoThrow() { await _repository.DeletePendingDeploymentByIdAsync("does-not-exist"); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("does-not-exist")); } [Fact] public async Task GetPendingDeploymentByInstanceId_ReturnsRow_WhenPresent() { // Startup-reconcile read path: when StagePendingIfAbsent reports a row already exists // (concurrent reconcile from the other node, or an in-flight deploy), the handler reads // the existing row by instance id to hand the second node the same fetch token. var instanceId = await SeedInstanceAsync("Inst9"); await _repository.AddPendingDeploymentAsync(NewPending("dep9", instanceId, "{\"v\":9}")); await _repository.SaveChangesAsync(); var row = await _repository.GetPendingDeploymentByInstanceIdAsync(instanceId); Assert.NotNull(row); Assert.Equal("dep9", row!.DeploymentId); Assert.Equal(instanceId, row.InstanceId); Assert.Equal("tok-dep9", row.Token); Assert.Equal("{\"v\":9}", row.ConfigurationJson); } [Fact] public async Task GetPendingDeploymentByInstanceId_ReturnsNull_WhenAbsent() { // No pending row staged for this instance → null (the reconcile fallback/omit path). var instanceId = await SeedInstanceAsync("Inst10"); var row = await _repository.GetPendingDeploymentByInstanceIdAsync(instanceId); Assert.Null(row); } [Fact] public async Task StagePendingIfAbsent_ExpiredRowDifferentDeploymentId_DoesNotBlock_RemovesExpiredAndStagesLiveRow() { // Live-found bug: pending rows are only TTL-purged (the periodic purge is a deferred TODO), // so an EXPIRED-but-unpurged row for the instance must NOT make staging report "a deploy is // in flight" — it would hand the node an expired token (HTTP 404) and leave it unhealed. // The expired row is removed and the fresh, live row is staged. var instanceId = await SeedInstanceAsync("InstExp1"); var now = DateTimeOffset.UtcNow; // Seed an EXPIRED pending row (different deploymentId), bypassing the repo. _context.Set().Add(new PendingDeployment( "dep-expired", instanceId, "rev-old", "{\"old\":true}", "tok-old", now.AddMinutes(-20), now.AddMinutes(-10))); await _context.SaveChangesAsync(); var staged = await _repository.StagePendingIfAbsentAsync( instanceId, "dep-fresh", "rev-fresh", "{\"fresh\":true}", "tok-fresh", now, now.AddMinutes(10)); Assert.True(staged); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-expired")); var row = await _repository.GetPendingDeploymentByIdAsync("dep-fresh"); Assert.NotNull(row); Assert.Equal(instanceId, row!.InstanceId); Assert.Equal("tok-fresh", row.Token); Assert.True(row.ExpiresAtUtc > now); // the new row is live } [Fact] public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_NoUniqueCollision_StagesNew() { // A fresh reconcile re-stages the snapshot's own DeploymentId. If an EXPIRED row already // carries that same DeploymentId, staging must remove it FIRST to avoid colliding on the // PendingDeployment.DeploymentId UNIQUE index — re-staging "D1" over an expired "D1" works. var instanceId = await SeedInstanceAsync("InstExp2"); var now = DateTimeOffset.UtcNow; _context.Set().Add(new PendingDeployment( "D1", instanceId, "rev-old", "{\"old\":true}", "tok-old", now.AddMinutes(-20), now.AddMinutes(-10))); await _context.SaveChangesAsync(); var staged = await _repository.StagePendingIfAbsentAsync( instanceId, "D1", "rev-new", "{\"new\":true}", "tok-new", now, now.AddMinutes(10)); Assert.True(staged); var row = await _repository.GetPendingDeploymentByIdAsync("D1"); Assert.NotNull(row); Assert.Equal("tok-new", row!.Token); Assert.Equal("{\"new\":true}", row.ConfigurationJson); Assert.True(row.ExpiresAtUtc > now); } [Fact] public async Task StagePendingIfAbsent_LiveRow_Blocks_LeavesUntouched() { // A still-LIVE pending row (future expiry) signals a genuine in-flight deploy or a concurrent // reconcile — staging must return false and leave it untouched (no supersession). var instanceId = await SeedInstanceAsync("InstLive"); var now = DateTimeOffset.UtcNow; _context.Set().Add(new PendingDeployment( "dep-live", instanceId, "rev-live", "{\"live\":true}", "tok-live", now.AddMinutes(-1), now.AddMinutes(9))); await _context.SaveChangesAsync(); var staged = await _repository.StagePendingIfAbsentAsync( instanceId, "dep-new", "rev-new", "{\"new\":true}", "tok-new", now, now.AddMinutes(10)); Assert.False(staged); var existing = await _repository.GetPendingDeploymentByIdAsync("dep-live"); Assert.NotNull(existing); Assert.Equal("tok-live", existing!.Token); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-new")); } [Fact] public async Task GetPendingDeploymentByInstanceId_WithNowUtc_ReturnsLiveRow_NotExpired() { // The by-instance getter must never hand back an EXPIRED row (whose token the config-fetch // endpoint would 404). With nowUtc supplied, an expired row is filtered out; a live row is returned. var expiredInst = await SeedInstanceAsync("InstGetExpired"); var liveInst = await SeedInstanceAsync("InstGetLive"); var now = DateTimeOffset.UtcNow; _context.Set().Add(new PendingDeployment( "dep-getexp", expiredInst, "rev", "{}", "tok-exp", now.AddMinutes(-20), now.AddMinutes(-10))); _context.Set().Add(new PendingDeployment( "dep-getlive", liveInst, "rev", "{}", "tok-live", now.AddMinutes(-1), now.AddMinutes(9))); await _context.SaveChangesAsync(); // Expired row filtered out when nowUtc is supplied. Assert.Null(await _repository.GetPendingDeploymentByInstanceIdAsync(expiredInst, now)); // Live row still returned. var live = await _repository.GetPendingDeploymentByInstanceIdAsync(liveInst, now); Assert.NotNull(live); Assert.Equal("dep-getlive", live!.DeploymentId); } [Fact] public async Task AddPendingDeployment_MultiplePriorRowsSameInstance_AllSuperseded() { // Guards the defensive ToListAsync + RemoveRange supersession path: if a prior // bug or corruption left >1 pending row for an instance, a new deploy must clear // them all (a refactor to FirstOrDefault + Remove would silently regress this). var instanceId = await SeedInstanceAsync("Inst8"); var now = DateTimeOffset.UtcNow; // Force two pending rows for the same instance, bypassing the repo, to simulate corruption. _context.Set().AddRange( new PendingDeployment("stale-a", instanceId, "rev", "{}", "tok", now.AddMinutes(-5), now.AddMinutes(5)), new PendingDeployment("stale-b", instanceId, "rev", "{}", "tok", now.AddMinutes(-3), now.AddMinutes(7))); await _context.SaveChangesAsync(); await _repository.AddPendingDeploymentAsync(NewPending("dep3", instanceId, "{\"v\":3}")); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("stale-a")); Assert.Null(await _repository.GetPendingDeploymentByIdAsync("stale-b")); Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("dep3")); } }