Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/ConcurrencyTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

268 lines
11 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
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.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// 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.
/// </summary>
public class ConcurrencyTestDbContext : ScadaBridgeDbContext
{
/// <summary>Initializes the context with the given options.</summary>
/// <param name="options">The context options.</param>
public ConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { }
/// <inheritdoc />
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<DeploymentRecord>(builder =>
{
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
builder.Property(d => d.Status).IsConcurrencyToken();
});
}
}
/// <summary>
/// A SQLite-friendly DbContext that keeps <see cref="DeploymentRecord.RowVersion"/> as
/// the optimistic-concurrency token but disables auto-generation (SQLite cannot
/// auto-populate a rowversion column). The caller sets RowVersion explicitly, which
/// is sufficient to exercise the production stub-attach delete path under the
/// concurrency rule.
/// </summary>
public class RowVersionConcurrencyTestDbContext : ScadaBridgeDbContext
{
/// <summary>Initializes the context with the given options.</summary>
/// <param name="options">The context options.</param>
public RowVersionConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { }
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<DeploymentRecord>(builder =>
{
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken()
.ValueGeneratedNever();
});
}
}
public class ConcurrencyTests : IDisposable
{
private readonly string _dbPath;
/// <summary>Creates a unique temporary SQLite database path for each test.</summary>
public ConcurrencyTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
if (File.Exists(_dbPath))
File.Delete(_dbPath);
}
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite($"DataSource={_dbPath}")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.Options;
return new ConcurrencyTestDbContext(options);
}
/// <summary>Verifies that a second update of a deployment record from a stale copy throws a concurrency exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<DbUpdateConcurrencyException>(
() => ctx2.SaveChangesAsync());
}
/// <summary>Verifies that templates have no optimistic concurrency, so the last write wins.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that deleting a deployment record with a stale row version throws a concurrency exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteDeploymentRecord_StaleRowVersion_ThrowsConcurrencyException()
{
// Verifies the stub-attach delete path enforces optimistic concurrency
// when the caller passes a RowVersion that no longer matches the row's current
// RowVersion. Uses a SQLite fixture where DeploymentRecord.RowVersion is an
// explicit, caller-managed concurrency token (no SQL Server auto-generation).
using var setupCtx = new RowVersionConcurrencyTestDbContext(BuildOptions());
await setupCtx.Database.EnsureCreatedAsync();
var site = new Site("Site1", "S-RV1");
var template = new Template("RV-T1");
setupCtx.Sites.Add(site);
setupCtx.Templates.Add(template);
await setupCtx.SaveChangesAsync();
var instance = new Instance("RV-I1") { SiteId = site.Id, TemplateId = template.Id, State = InstanceState.Enabled };
setupCtx.Instances.Add(instance);
await setupCtx.SaveChangesAsync();
var record = new DeploymentRecord("deploy-rv-stale", "admin")
{
InstanceId = instance.Id,
DeployedAt = DateTimeOffset.UtcNow,
RowVersion = new byte[] { 0x01 },
};
setupCtx.DeploymentRecords.Add(record);
await setupCtx.SaveChangesAsync();
var id = record.Id;
// Reload in a fresh context and simulate a concurrent edit that has advanced
// the stored RowVersion. The caller below holds the *prior* RowVersion (0x01)
// and is expected to lose the concurrency check.
using (var advanceCtx = new RowVersionConcurrencyTestDbContext(BuildOptions()))
{
var stored = await advanceCtx.DeploymentRecords.SingleAsync(d => d.Id == id);
stored.RowVersion = new byte[] { 0x02 };
await advanceCtx.SaveChangesAsync();
}
using var deleteCtx = new RowVersionConcurrencyTestDbContext(BuildOptions());
var repository = new DeploymentManagerRepository(deleteCtx);
var staleRowVersion = new byte[] { 0x01 };
await repository.DeleteDeploymentRecordAsync(id, staleRowVersion);
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(
() => repository.SaveChangesAsync());
}
private DbContextOptions<ScadaBridgeDbContext> BuildOptions()
{
return new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite($"DataSource={_dbPath}")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.Options;
}
/// <summary>Verifies that the <c>DeploymentRecord</c> row version is configured as a concurrency token.</summary>
[Fact]
public void DeploymentRecord_HasRowVersionConfigured()
{
// Verify the production configuration has a RowVersion shadow property
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite("DataSource=:memory:")
.Options;
using var context = new ScadaBridgeDbContext(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);
}
}