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.Commons.Types.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests; /// /// Coverage for the startup-reconciliation repo methods on : /// (central's deployed /// set for a site, excluding instances with no snapshot) and /// (insert-if-absent, used by /// the reconcile handler to mint fresh fetch tokens for the gap without clobbering an in-flight deploy). /// Uses the shared SQLite in-memory fixture with real FK enforcement. /// public class ReconcileRepositoryTests : IDisposable { private readonly ScadaBridgeDbContext _context; private readonly DeploymentManagerRepository _repository; public ReconcileRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new DeploymentManagerRepository(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } // --- Seed helpers --- /// Seeds a Site row and returns its generated id. private async Task SeedSiteAsync(string name) { var site = new Site(name, $"SI-{name}"); _context.Sites.Add(site); await _context.SaveChangesAsync(); return site.Id; } /// Seeds a Template + Instance on the given site and returns the instance id. private async Task SeedInstanceAsync(string uniqueName, int siteId, InstanceState state = InstanceState.Enabled) { var template = new Template($"T-{uniqueName}"); _context.Templates.Add(template); await _context.SaveChangesAsync(); var instance = new Instance(uniqueName) { SiteId = siteId, TemplateId = template.Id, State = state }; _context.Instances.Add(instance); await _context.SaveChangesAsync(); return instance.Id; } /// Seeds a for the given instance and returns it. private async Task SeedSnapshotAsync(int instanceId, string deploymentId, string revisionHash) { var snapshot = new DeployedConfigSnapshot(deploymentId, revisionHash, "{}") { InstanceId = instanceId }; _context.Set().Add(snapshot); await _context.SaveChangesAsync(); return snapshot; } // --- GetExpectedDeploymentsForSiteAsync --- [Fact] public async Task GetExpectedDeployments_ReturnsOnlyDeployedInstancesForSite() { // Arrange: site A has two deployed instances (one Enabled, one Disabled) // site B has one deployed instance // site A also has one instance WITHOUT a snapshot (must be excluded) var siteAId = await SeedSiteAsync("SiteA"); var siteBId = await SeedSiteAsync("SiteB"); var instA1Id = await SeedInstanceAsync("A-Inst1", siteAId, InstanceState.Enabled); var instA2Id = await SeedInstanceAsync("A-Inst2", siteAId, InstanceState.Disabled); var instA3Id = await SeedInstanceAsync("A-Inst3-NoSnap", siteAId, InstanceState.Enabled); var instB1Id = await SeedInstanceAsync("B-Inst1", siteBId, InstanceState.Enabled); await SeedSnapshotAsync(instA1Id, "dep-a1", "hash-a1"); await SeedSnapshotAsync(instA2Id, "dep-a2", "hash-a2"); // instA3 intentionally has no snapshot await SeedSnapshotAsync(instB1Id, "dep-b1", "hash-b1"); // Act var result = await _repository.GetExpectedDeploymentsForSiteAsync(siteAId); // Assert: exactly the two site-A instances that have snapshots Assert.Equal(2, result.Count); var byName = result.ToDictionary(r => r.InstanceUniqueName); Assert.True(byName.ContainsKey("A-Inst1"), "A-Inst1 should be present"); Assert.True(byName.ContainsKey("A-Inst2"), "A-Inst2 should be present"); Assert.False(byName.ContainsKey("A-Inst3-NoSnap"), "instance without snapshot must be excluded"); Assert.False(byName.ContainsKey("B-Inst1"), "site B instance must be excluded"); // Check field correctness for A-Inst1 (Enabled) var a1 = byName["A-Inst1"]; Assert.Equal(instA1Id, a1.InstanceId); Assert.Equal("dep-a1", a1.DeploymentId); Assert.Equal("hash-a1", a1.RevisionHash); Assert.True(a1.IsEnabled); // Check field correctness for A-Inst2 (Disabled) var a2 = byName["A-Inst2"]; Assert.Equal(instA2Id, a2.InstanceId); Assert.Equal("dep-a2", a2.DeploymentId); Assert.Equal("hash-a2", a2.RevisionHash); Assert.False(a2.IsEnabled); } [Fact] public async Task GetExpectedDeployments_NoDeployedInstances_ReturnsEmpty() { var siteId = await SeedSiteAsync("EmptySite"); // Seed an instance but no snapshot await SeedInstanceAsync("Inst-NoSnap", siteId); var result = await _repository.GetExpectedDeploymentsForSiteAsync(siteId); Assert.Empty(result); } // --- StagePendingIfAbsentAsync --- [Fact] public async Task StagePendingIfAbsent_NoPriorRow_StagesAndReturnsTrue() { // Arrange var siteId = await SeedSiteAsync("SiteX"); var instanceId = await SeedInstanceAsync("InstX", siteId); var now = DateTimeOffset.UtcNow; // Act var staged = await _repository.StagePendingIfAbsentAsync( instanceId, deploymentId: "dep-new", revisionHash: "rev-new", configurationJson: "{\"x\":1}", token: "tok-new", createdAtUtc: now, expiresAtUtc: now.AddMinutes(10)); // Assert: staged, and the row is retrievable with the correct token + config Assert.True(staged); var row = await _repository.GetPendingDeploymentByIdAsync("dep-new"); Assert.NotNull(row); Assert.Equal(instanceId, row!.InstanceId); Assert.Equal("tok-new", row.Token); Assert.Equal("{\"x\":1}", row.ConfigurationJson); Assert.Equal("rev-new", row.RevisionHash); } [Fact] public async Task StagePendingIfAbsent_PriorRowExists_ReturnsFalseAndDoesNotOverwrite() { // Arrange: an existing pending row for the instance (simulates an in-flight deploy) var siteId = await SeedSiteAsync("SiteY"); var instanceId = await SeedInstanceAsync("InstY", siteId); var now = DateTimeOffset.UtcNow; var existing = new PendingDeployment( "dep-existing", instanceId, revisionHash: "rev-existing", configurationJson: "{\"existing\":true}", token: "tok-existing", createdAtUtc: now.AddMinutes(-5), expiresAtUtc: now.AddMinutes(5)); _context.Set().Add(existing); await _context.SaveChangesAsync(); // Act: try to stage a newer row for the same instance var staged = await _repository.StagePendingIfAbsentAsync( instanceId, deploymentId: "dep-reconcile", revisionHash: "rev-reconcile", configurationJson: "{\"reconcile\":true}", token: "tok-reconcile", createdAtUtc: now, expiresAtUtc: now.AddMinutes(10)); // Assert: skipped (returns false), existing row is unchanged Assert.False(staged); var existingRow = await _repository.GetPendingDeploymentByIdAsync("dep-existing"); Assert.NotNull(existingRow); Assert.Equal("tok-existing", existingRow!.Token); Assert.Equal("{\"existing\":true}", existingRow.ConfigurationJson); // The reconcile row must not exist Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-reconcile")); } }