feat: wire SQLite replication between site nodes and fix ConfigurationDatabase tests

Add SiteReplicationActor (runs on every site node) to replicate deployed
configs and store-and-forward buffer operations to the standby peer via
cluster member discovery and fire-and-forget Tell. Wire ReplicationService
handler and pass replication actor to DeploymentManagerActor singleton.

Fix 5 pre-existing ConfigurationDatabase test failures: RowVersion NOT NULL
on SQLite, stale migration name assertion, and seed data count mismatch.
This commit is contained in:
Joseph Doherty
2026-03-18 08:28:02 -04:00
parent f063fb1ca3
commit eb8ead58d2
23 changed files with 707 additions and 33 deletions

View File

@@ -22,12 +22,15 @@ public class ConcurrencyTestDbContext : ScadaLinkDbContext
{
base.OnModelCreating(modelBuilder);
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite
// Remove the shadow RowVersion property and add a visible ConcurrencyStamp
// 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 =>
{
// The shadow RowVersion property from the base config doesn't work in SQLite.
// Instead, use Status as a concurrency token for the test.
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
builder.Property(d => d.Status).IsConcurrencyToken();
});
}

View File

@@ -68,8 +68,9 @@ public class SecurityRepositoryTests : IDisposable
await _repository.SaveChangesAsync();
var designMappings = await _repository.GetMappingsByRoleAsync("Design");
Assert.Single(designMappings);
Assert.Equal("Designers", designMappings[0].LdapGroupName);
// Seed data includes "SCADA-Designers" with role "Design", plus the one we added
Assert.Equal(2, designMappings.Count);
Assert.Contains(designMappings, m => m.LdapGroupName == "Designers");
}
[Fact]

View File

@@ -1,13 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using ScadaLink.Commons.Entities.Deployment;
using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.ConfigurationDatabase.Tests;
/// <summary>
/// Test DbContext that maps DateTimeOffset to a sortable string format for SQLite.
/// EF Core 10 SQLite provider does not support ORDER BY on DateTimeOffset columns.
/// Test DbContext that adapts SQL Server-specific features for SQLite:
/// - Maps DateTimeOffset to sortable ISO 8601 strings (SQLite has no native DateTimeOffset ORDER BY)
/// - Replaces SQL Server RowVersion with a nullable byte[] column (SQLite can't auto-generate rowversion)
/// </summary>
public class SqliteTestDbContext : ScadaLinkDbContext
{
@@ -19,6 +21,16 @@ public class SqliteTestDbContext : ScadaLinkDbContext
{
base.OnModelCreating(modelBuilder);
// SQLite cannot auto-generate SQL Server rowversion values.
// Replace with a nullable byte[] column so inserts don't fail with NOT NULL constraint.
modelBuilder.Entity<DeploymentRecord>(builder =>
{
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
});
// Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works
var converter = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("o"),

View File

@@ -21,13 +21,7 @@ public class DbContextTests : IDisposable
public DbContextTests()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlite("DataSource=:memory:")
.Options;
_context = new ScadaLinkDbContext(options);
_context.Database.OpenConnection();
_context.Database.EnsureCreated();
_context = SqliteTestHelper.CreateInMemoryContext();
}
public void Dispose()
@@ -429,6 +423,6 @@ public class MigrationHelperTests : IDisposable
{
// Verify the InitialCreate migration is detected as pending
var pending = _context.Database.GetPendingMigrations().ToList();
Assert.Contains(pending, m => m.Contains("InitialCreate"));
Assert.Contains(pending, m => m.Contains("InitialSchema"));
}
}