using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Entities.Instances; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Types.Enums; using ScadaLink.ConfigurationDatabase; namespace ScadaLink.ConfigurationDatabase.Tests; /// /// A test-specific DbContext that uses an explicit ConcurrencyToken on DeploymentRecord /// (as opposed to SQL Server's IsRowVersion()) so that SQLite can enforce concurrency. /// In production, the SQL Server RowVersion provides this automatically. /// public class ConcurrencyTestDbContext : ScadaLinkDbContext { public ConcurrencyTestDbContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // Replace the SQL Server RowVersion with an explicit concurrency token for SQLite. // SQLite can't auto-generate rowversion, so disable it and use Status as the token instead. modelBuilder.Entity(builder => { builder.Property(d => d.RowVersion) .IsRequired(false) .IsConcurrencyToken(false) .ValueGeneratedNever(); builder.Property(d => d.Status).IsConcurrencyToken(); }); } } public class ConcurrencyTests : IDisposable { private readonly string _dbPath; public ConcurrencyTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db"); } public void Dispose() { if (File.Exists(_dbPath)) File.Delete(_dbPath); } private ScadaLinkDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseSqlite($"DataSource={_dbPath}") .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) .Options; return new ConcurrencyTestDbContext(options); } [Fact] public async Task DeploymentRecord_OptimisticConcurrency_SecondUpdateThrows() { // Setup: create necessary entities using (var setupCtx = CreateContext()) { await setupCtx.Database.EnsureCreatedAsync(); var site = new Site("Site1", "S-001"); var template = new Template("T1"); setupCtx.Sites.Add(site); setupCtx.Templates.Add(template); await setupCtx.SaveChangesAsync(); var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id, State = InstanceState.Enabled }; setupCtx.Instances.Add(instance); await setupCtx.SaveChangesAsync(); var record = new DeploymentRecord("deploy-concurrent", "admin") { InstanceId = instance.Id, Status = DeploymentStatus.Pending, DeployedAt = DateTimeOffset.UtcNow }; setupCtx.DeploymentRecords.Add(record); await setupCtx.SaveChangesAsync(); } // Load the same record in two separate contexts using var ctx1 = CreateContext(); using var ctx2 = CreateContext(); var record1 = await ctx1.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent"); var record2 = await ctx2.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent"); // Both loaded Status = Pending. First context updates and saves successfully. record1.Status = DeploymentStatus.Success; record1.CompletedAt = DateTimeOffset.UtcNow; await ctx1.SaveChangesAsync(); // Second context tries to update the same record from the stale "Pending" state — should throw // because the Status concurrency token has changed from Pending to Success record2.Status = DeploymentStatus.Failed; await Assert.ThrowsAsync( () => ctx2.SaveChangesAsync()); } [Fact] public async Task Template_NoOptimisticConcurrency_LastWriteWins() { // Setup using (var setupCtx = CreateContext()) { await setupCtx.Database.EnsureCreatedAsync(); var template = new Template("ConcurrentTemplate") { Description = "Original" }; setupCtx.Templates.Add(template); await setupCtx.SaveChangesAsync(); } // Load in two contexts using var ctx1 = CreateContext(); using var ctx2 = CreateContext(); var template1 = await ctx1.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); var template2 = await ctx2.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); // First update template1.Description = "First update"; await ctx1.SaveChangesAsync(); // Second update — should succeed (last-write-wins, no concurrency token) template2.Description = "Second update"; await ctx2.SaveChangesAsync(); // Should NOT throw // Verify last write won using var verifyCtx = CreateContext(); var loaded = await verifyCtx.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); Assert.Equal("Second update", loaded.Description); } [Fact] public void DeploymentRecord_HasRowVersionConfigured() { // Verify the production configuration has a RowVersion shadow property var options = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; using var context = new ScadaLinkDbContext(options); context.Database.OpenConnection(); context.Database.EnsureCreated(); var entityType = context.Model.FindEntityType(typeof(DeploymentRecord))!; var rowVersion = entityType.FindProperty("RowVersion"); Assert.NotNull(rowVersion); Assert.True(rowVersion!.IsConcurrencyToken); } }