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; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; using Xunit; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories; /// /// SQL Server integration coverage for the notify-and-fetch /// staging store's same-DeploymentId re-stage path. The SQLite in-memory fixture /// (PendingDeploymentRepositoryTests) covers the logic, but the delete-before-insert /// ordering that lets /// re-stage an instance's OWN DeploymentId over an expired row depends on EF emitting /// the DELETE before the INSERT within a single SaveChanges — against the production /// UNIQUE index on DeploymentId. SQLite's constraint timing differs from SQL Server's /// (SQLite defers/relaxes within a transaction where SQL Server enforces per-statement), so /// this class asserts the behaviour against the real migrated schema via /// rather than the SQLite provider. /// public class PendingDeploymentRepositoryIntegrationTests : IClassFixture { private readonly MsSqlMigrationFixture _fixture; public PendingDeploymentRepositoryIntegrationTests(MsSqlMigrationFixture fixture) { _fixture = fixture; } [SkippableFact] public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_ReStages_NoUniqueViolation() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var instanceId = await SeedInstanceAsync("ReStageInst"); var now = DateTimeOffset.UtcNow; // Seed an EXPIRED pending row carrying DeploymentId "D1". A startup reconcile re-stages // the DeployedConfigSnapshot's OWN DeploymentId, so the fresh row reuses "D1" — the exact // collision the unique index would reject if the DELETE didn't precede the INSERT. await using (var seedContext = CreateContext()) { seedContext.Set().Add(new PendingDeployment( "D1", instanceId, "rev-old", "{\"old\":true}", "tok-old", now.AddMinutes(-20), now.AddMinutes(-10))); await seedContext.SaveChangesAsync(); } // Re-stage "D1" over the expired row. Against the real UNIQUE index on DeploymentId this // MUST succeed (EF orders delete-before-insert in the single SaveChanges) rather than // throwing SqlException 2627/2601. await using (var context = CreateContext()) { var repo = new DeploymentManagerRepository(context); var staged = await repo.StagePendingIfAbsentAsync( instanceId, "D1", "rev-new", "{\"new\":true}", "tok-new", now, now.AddMinutes(10)); Assert.True(staged); } // Exactly one row for "D1" survives — the fresh one. await using (var readContext = CreateContext()) { var rows = await readContext.Set() .Where(p => p.DeploymentId == "D1") .ToListAsync(); Assert.Single(rows); Assert.Equal("tok-new", rows[0].Token); Assert.Equal("{\"new\":true}", rows[0].ConfigurationJson); Assert.True(rows[0].ExpiresAtUtc > now); } } [SkippableFact] public async Task AddPendingDeployment_NewDeployDifferentId_SupersedesPriorRow_OnRealSqlServer() { Skip.IfNot(_fixture.Available, _fixture.SkipReason); var instanceId = await SeedInstanceAsync("SupersedeInst"); var now = DateTimeOffset.UtcNow; await using (var context = CreateContext()) { var repo = new DeploymentManagerRepository(context); await repo.AddPendingDeploymentAsync(new PendingDeployment( "dep-A", instanceId, "rev-a", "{\"v\":1}", "tok-a", now, now.AddMinutes(5))); await repo.SaveChangesAsync(); } // A newer deploy for the SAME instance with a DIFFERENT DeploymentId must replace the // prior row (delete-then-insert under the per-instance operation lock) with no FK or // unique conflict on real SQL Server. await using (var context = CreateContext()) { var repo = new DeploymentManagerRepository(context); await repo.AddPendingDeploymentAsync(new PendingDeployment( "dep-B", instanceId, "rev-b", "{\"v\":2}", "tok-b", now, now.AddMinutes(5))); await repo.SaveChangesAsync(); } await using (var readContext = CreateContext()) { var rows = await readContext.Set() .Where(p => p.InstanceId == instanceId) .ToListAsync(); Assert.Single(rows); Assert.Equal("dep-B", rows[0].DeploymentId); Assert.Equal("{\"v\":2}", rows[0].ConfigurationJson); } } /// /// Seeds an Instance (with its required Site + Template) against the fixture database and /// returns its generated id. The PendingDeployment → Instance FK requires a real parent row. /// private async Task SeedInstanceAsync(string uniqueName) { await using var context = CreateContext(); 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 ScadaBridgeDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString) .Options; return new ScadaBridgeDbContext(options); } }