refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class AuditServiceTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly AuditCorrelationContext _correlationContext;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
public AuditServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaBridgeDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
_correlationContext = new AuditCorrelationContext();
|
||||
_auditService = new AuditService(_context, _correlationContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_CreatesAuditEntry_CommittedWithEntityChange()
|
||||
{
|
||||
// Simulate entity change + audit in same transaction
|
||||
var template = new Template("TestTemplate");
|
||||
_context.Templates.Add(template);
|
||||
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "TestTemplate",
|
||||
new { Name = "TestTemplate" });
|
||||
|
||||
// Single SaveChangesAsync commits both
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.Equal("admin", audit.User);
|
||||
Assert.Equal("Create", audit.Action);
|
||||
Assert.Equal("Template", audit.EntityType);
|
||||
Assert.NotNull(audit.AfterStateJson);
|
||||
|
||||
// Template also committed
|
||||
Assert.Single(await _context.Templates.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_Rollback_BothChangeAndAuditRolledBack()
|
||||
{
|
||||
// Use a separate context to simulate rollback via not calling SaveChanges
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite(_context.Database.GetDbConnection())
|
||||
.Options;
|
||||
|
||||
using var context2 = new ScadaBridgeDbContext(options);
|
||||
var auditService2 = new AuditService(context2, new AuditCorrelationContext());
|
||||
|
||||
var template = new Template("RollbackTemplate");
|
||||
context2.Templates.Add(template);
|
||||
await auditService2.LogAsync("admin", "Create", "Template", "99", "RollbackTemplate",
|
||||
new { Name = "RollbackTemplate" });
|
||||
|
||||
// Intentionally do NOT call SaveChangesAsync — simulates rollback
|
||||
// Verify nothing persisted
|
||||
Assert.Empty(await _context.AuditLogEntries.Where(a => a.EntityName == "RollbackTemplate").ToListAsync());
|
||||
Assert.Empty(await _context.Templates.Where(t => t.Name == "RollbackTemplate").ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_SerializesAfterStateAsJson()
|
||||
{
|
||||
var state = new { Name = "Test", Value = 42, Nested = new { Prop = "inner" } };
|
||||
await _auditService.LogAsync("admin", "Create", "Entity", "1", "Test", state);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.NotNull(audit.AfterStateJson);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<JsonElement>(audit.AfterStateJson!);
|
||||
Assert.Equal("Test", deserialized.GetProperty("Name").GetString());
|
||||
Assert.Equal(42, deserialized.GetProperty("Value").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_NullAfterState_ForDeletes()
|
||||
{
|
||||
await _auditService.LogAsync("admin", "Delete", "Template", "1", "DeletedTemplate", null);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.Null(audit.AfterStateJson);
|
||||
Assert.Equal("Delete", audit.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_SetsTimestampToUtcNow()
|
||||
{
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "T1", new { });
|
||||
await _context.SaveChangesAsync();
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
// Allow 2 second tolerance for SQLite precision
|
||||
Assert.True(audit.Timestamp >= before.AddSeconds(-2));
|
||||
Assert.True(audit.Timestamp <= after.AddSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditService_IsAppendOnly_NoUpdateOrDeleteMethods()
|
||||
{
|
||||
// Verify IAuditService only exposes LogAsync — no update/delete
|
||||
var methods = typeof(IAuditService).GetMethods();
|
||||
Assert.Single(methods, m => m.Name == "LogAsync");
|
||||
Assert.DoesNotContain(methods, m => m.Name.Contains("Update", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_StampsBundleImportId_FromCorrelationContext()
|
||||
{
|
||||
// Bundle importer sets the correlation context for the duration of ApplyAsync;
|
||||
// every AuditLogEntry written under that scope must carry the BundleImportId so
|
||||
// the imported configuration is attributable to the import session.
|
||||
var bundleId = Guid.NewGuid();
|
||||
_correlationContext.BundleImportId = bundleId;
|
||||
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "BundleImportedTemplate",
|
||||
new { Name = "BundleImportedTemplate" });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.Equal(bundleId, audit.BundleImportId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_LeavesBundleImportIdNull_WhenCorrelationContextHasNoValue()
|
||||
{
|
||||
// Default code path (interactive user edit, not a bundle import) must leave
|
||||
// the column NULL so normal audit rows are distinguishable from bundle-import
|
||||
// rows in queries and reports.
|
||||
Assert.Null(_correlationContext.BundleImportId);
|
||||
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "InteractiveTemplate",
|
||||
new { Name = "InteractiveTemplate" });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.Null(audit.BundleImportId);
|
||||
}
|
||||
|
||||
// Self-referential POCO used to reproduce a reference cycle in afterState.
|
||||
private sealed class CyclicNode
|
||||
{
|
||||
public string Name { get; set; } = "node";
|
||||
public CyclicNode? Self { get; set; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_AfterStateWithReferenceCycle_DoesNotThrow_AndDoesNotRollBackOperation()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-007: serializing an afterState
|
||||
// object that contains a reference cycle must not throw a JsonException —
|
||||
// that would roll back the entire business operation it is auditing.
|
||||
var node = new CyclicNode();
|
||||
node.Self = node; // reference cycle
|
||||
|
||||
var template = new Template("CyclicAuditTemplate");
|
||||
_context.Templates.Add(template);
|
||||
|
||||
// Must not throw.
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "CyclicAuditTemplate", node);
|
||||
|
||||
// The audited business operation must still commit successfully.
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.NotNull(audit.AfterStateJson);
|
||||
Assert.Contains("node", audit.AfterStateJson);
|
||||
Assert.Single(await _context.Templates.Where(t => t.Name == "CyclicAuditTemplate").ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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
|
||||
{
|
||||
public ConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> 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<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 CD-017's
|
||||
/// concurrency rule.
|
||||
/// </summary>
|
||||
public class RowVersionConcurrencyTestDbContext : ScadaBridgeDbContext
|
||||
{
|
||||
public RowVersionConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { }
|
||||
|
||||
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;
|
||||
|
||||
public ConcurrencyTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
[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 async Task DeleteDeploymentRecord_StaleRowVersion_ThrowsConcurrencyException()
|
||||
{
|
||||
// CD-017: 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;
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Schema-level tests for <see cref="AuditLogEntityTypeConfiguration"/> (#23 M1 Bundle B).
|
||||
/// Verifies that <see cref="AuditEvent"/> maps to the AuditLog table with the
|
||||
/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4.
|
||||
/// Inspects EF model metadata via the existing in-memory SQLite test context — no
|
||||
/// database round-trips required.
|
||||
/// </summary>
|
||||
public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public AuditLogEntityTypeConfigurationTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey()
|
||||
{
|
||||
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
|
||||
// AuditLog table — the clustered key must include the partition column
|
||||
// (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C).
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
|
||||
Assert.NotNull(entity);
|
||||
Assert.Equal("AuditLog", entity!.GetTableName());
|
||||
|
||||
var pk = entity.FindPrimaryKey();
|
||||
Assert.NotNull(pk);
|
||||
|
||||
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups()
|
||||
{
|
||||
// EventId remains globally unique (the idempotency key for
|
||||
// InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that
|
||||
// is independent of the composite PK.
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var eventIdIndex = entity!.GetIndexes()
|
||||
.SingleOrDefault(i => i.GetDatabaseName() == "UX_AuditLog_EventId");
|
||||
|
||||
Assert.NotNull(eventIdIndex);
|
||||
Assert.True(eventIdIndex!.IsUnique);
|
||||
|
||||
var indexedProperty = Assert.Single(eventIdIndex.Properties);
|
||||
Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_HasExpectedPropertyCount()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var properties = entity!.GetProperties()
|
||||
.Where(p => !p.IsShadowProperty())
|
||||
.ToList();
|
||||
|
||||
// AuditEvent record exposes 24 init-only properties (alog.md §4 plus the
|
||||
// additive ExecutionId universal correlation column, its ParentExecutionId
|
||||
// sibling, and the SourceNode-stamping column).
|
||||
Assert.Equal(24, properties.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_ExpectedIndexes_WithCorrectNames()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var indexNames = entity!.GetIndexes()
|
||||
.Select(i => i.GetDatabaseName())
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
|
||||
// index introduced alongside the composite PK (Bundle C), plus the additive
|
||||
// IX_AuditLog_Execution index supporting ExecutionId lookups, the
|
||||
// IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups,
|
||||
// and the IX_AuditLog_Node_Occurred composite supporting per-node queries
|
||||
// (SourceNode-stamping).
|
||||
var expected = new[]
|
||||
{
|
||||
"IX_AuditLog_Channel_Status_Occurred",
|
||||
"IX_AuditLog_CorrelationId",
|
||||
"IX_AuditLog_Execution",
|
||||
"IX_AuditLog_Node_Occurred",
|
||||
"IX_AuditLog_OccurredAtUtc",
|
||||
"IX_AuditLog_ParentExecution",
|
||||
"IX_AuditLog_Site_Occurred",
|
||||
"IX_AuditLog_Target_Occurred",
|
||||
"UX_AuditLog_EventId",
|
||||
};
|
||||
|
||||
Assert.Equal(expected, indexNames);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuditEvent.Channel))]
|
||||
[InlineData(nameof(AuditEvent.Kind))]
|
||||
[InlineData(nameof(AuditEvent.Status))]
|
||||
[InlineData(nameof(AuditEvent.ForwardState))]
|
||||
public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var property = entity!.FindProperty(propertyName);
|
||||
Assert.NotNull(property);
|
||||
|
||||
// Enums are converted to strings (varchar(32) IsUnicode=false on SQL Server).
|
||||
Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType);
|
||||
Assert.Equal(32, property.GetMaxLength());
|
||||
Assert.False(property.IsUnicode() ?? true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
|
||||
{
|
||||
// Insert an AuditEvent with an Unspecified-Kind DateTime, then re-read
|
||||
// it in a fresh context. The UtcConverter on the OccurredAtUtc /
|
||||
// IngestedAtUtc columns must re-tag the round-tripped value as
|
||||
// DateTimeKind.Utc. Without the converter the SQLite (and on production
|
||||
// SQL Server, datetime2) provider would yield Kind=Unspecified — see
|
||||
// ConfigurationDatabase-018/020 and Commons-019.
|
||||
var unspecifiedOccurred = new DateTime(2026, 5, 28, 10, 30, 0, DateTimeKind.Unspecified);
|
||||
var unspecifiedIngested = new DateTime(2026, 5, 28, 10, 31, 0, DateTimeKind.Unspecified);
|
||||
var eventId = Guid.NewGuid();
|
||||
var siteId = "test-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
// The AuditEvent record's init-setter (Commons-019 resolution)
|
||||
// re-tags Unspecified values as Utc on assignment, so the value EF
|
||||
// ultimately writes already has Kind=Utc. The converter's job is
|
||||
// to keep the Kind tag on the READ path, which the assertions
|
||||
// below exercise.
|
||||
OccurredAtUtc = unspecifiedOccurred,
|
||||
IngestedAtUtc = unspecifiedIngested,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
SourceSiteId = siteId,
|
||||
};
|
||||
|
||||
_context.Set<AuditEvent>().Add(evt);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Detach the tracked entity and re-read in a fresh query so we exercise
|
||||
// the actual hydrate path, not the change-tracker cache.
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.Set<AuditEvent>()
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.SingleAsync();
|
||||
|
||||
Assert.Equal(DateTimeKind.Utc, loaded.OccurredAtUtc.Kind);
|
||||
Assert.NotNull(loaded.IngestedAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, loaded.IngestedAtUtc!.Value.Kind);
|
||||
|
||||
// The timestamp ticks must round-trip unchanged — the converter only
|
||||
// touches the Kind flag, not the wall-clock value.
|
||||
Assert.Equal(unspecifiedOccurred.Ticks, loaded.OccurredAtUtc.Ticks);
|
||||
Assert.Equal(unspecifiedIngested.Ticks, loaded.IngestedAtUtc.Value.Ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_OccurredAtUtcAndIngestedAtUtc_HaveUtcValueConverters()
|
||||
{
|
||||
// Model-metadata cross-check on the converter wiring — guards against a
|
||||
// future config refactor accidentally removing the HasConversion calls.
|
||||
// The converter type itself is internal to the configuration, so we
|
||||
// just assert SOME converter is present on each *Utc DateTime column.
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var occurredAt = entity!.FindProperty(nameof(AuditEvent.OccurredAtUtc));
|
||||
Assert.NotNull(occurredAt);
|
||||
Assert.NotNull(occurredAt!.GetValueConverter());
|
||||
|
||||
var ingestedAt = entity.FindProperty(nameof(AuditEvent.IngestedAtUtc));
|
||||
Assert.NotNull(ingestedAt);
|
||||
Assert.NotNull(ingestedAt!.GetValueConverter());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_FilteredIndexes_HaveExpectedFilters()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var correlationIdx = entity!.GetIndexes()
|
||||
.Single(i => i.GetDatabaseName() == "IX_AuditLog_CorrelationId");
|
||||
Assert.Equal("[CorrelationId] IS NOT NULL", correlationIdx.GetFilter());
|
||||
|
||||
var targetIdx = entity.GetIndexes()
|
||||
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
|
||||
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
|
||||
|
||||
var executionIdx = entity.GetIndexes()
|
||||
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
|
||||
Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter());
|
||||
|
||||
var parentExecutionIdx = entity.GetIndexes()
|
||||
.Single(i => i.GetDatabaseName() == "IX_AuditLog_ParentExecution");
|
||||
Assert.Equal("[ParentExecutionId] IS NOT NULL", parentExecutionIdx.GetFilter());
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Schema-level tests for <see cref="SiteCallEntityTypeConfiguration"/> (#22 / #23 M3 Bundle B).
|
||||
/// Verifies the <see cref="SiteCall"/> record maps to the <c>SiteCalls</c> table with the
|
||||
/// expected primary key, value conversion on <c>TrackedOperationId</c>, and the two named
|
||||
/// indexes that back the "calls from this site" and "calls in this status" Central UI queries.
|
||||
/// Mirrors the AuditLog Bundle B test pattern — inspects EF model metadata via the existing
|
||||
/// in-memory SQLite test context, no database round-trips required.
|
||||
/// </summary>
|
||||
public class SiteCallEntityTypeConfigurationTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public SiteCallEntityTypeConfigurationTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_MapsToSiteCallsTable()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
|
||||
Assert.NotNull(entity);
|
||||
Assert.Equal("SiteCalls", entity!.GetTableName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_PrimaryKey_TrackedOperationId()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var pk = entity!.FindPrimaryKey();
|
||||
Assert.NotNull(pk);
|
||||
|
||||
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_HasIndexes_NamedAndOrdered()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var indexes = entity!.GetIndexes().ToList();
|
||||
|
||||
// IX_SiteCalls_Source_Created: (SourceSite ASC, CreatedAtUtc DESC).
|
||||
var sourceCreated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Source_Created");
|
||||
Assert.NotNull(sourceCreated);
|
||||
var sourceCreatedProps = sourceCreated!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.CreatedAtUtc) }, sourceCreatedProps);
|
||||
|
||||
// IX_SiteCalls_Status_Updated: (Status ASC, UpdatedAtUtc DESC).
|
||||
var statusUpdated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Status_Updated");
|
||||
Assert.NotNull(statusUpdated);
|
||||
var statusUpdatedProps = statusUpdated!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_TrackedOperationId_ConvertedToString_Length36()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var property = entity!.FindProperty(nameof(SiteCall.TrackedOperationId));
|
||||
Assert.NotNull(property);
|
||||
|
||||
// Stored as varchar(36) (TrackedOperationId.ToString("D") is always 36 chars).
|
||||
// The value-conversion target type is exposed via GetProviderClrType when set, or
|
||||
// discovered indirectly through the configured converter; either way the on-wire
|
||||
// CLR type is string.
|
||||
var providerClrType = property!.GetProviderClrType() ?? property.GetValueConverter()?.ProviderClrType;
|
||||
Assert.Equal(typeof(string), providerClrType);
|
||||
Assert.Equal(36, property.GetMaxLength());
|
||||
Assert.False(property.IsUnicode() ?? true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(SiteCall.Channel), 32)]
|
||||
[InlineData(nameof(SiteCall.SourceSite), 64)]
|
||||
[InlineData(nameof(SiteCall.Status), 32)]
|
||||
[InlineData(nameof(SiteCall.Target), 256)]
|
||||
public void Configure_AsciiBoundedColumns(string propertyName, int expectedMaxLength)
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var property = entity!.FindProperty(propertyName);
|
||||
Assert.NotNull(property);
|
||||
|
||||
Assert.Equal(expectedMaxLength, property!.GetMaxLength());
|
||||
Assert.False(property.IsUnicode() ?? true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class DataProtectionTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DataProtectionTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_dp_test_{Guid.NewGuid()}.db");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
File.Delete(_dbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedDataProtection_ProtectAndUnprotect_AcrossContainers()
|
||||
{
|
||||
var connectionString = $"DataSource={_dbPath}";
|
||||
|
||||
// Create the database schema
|
||||
var setupOptions = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite(connectionString)
|
||||
.Options;
|
||||
using (var setupCtx = new ScadaBridgeDbContext(setupOptions))
|
||||
{
|
||||
setupCtx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
// Container 1: protect some data
|
||||
var services1 = new ServiceCollection();
|
||||
services1.AddDbContext<ScadaBridgeDbContext>(opt => opt.UseSqlite(connectionString));
|
||||
services1.AddDataProtection()
|
||||
.SetApplicationName("ScadaBridge")
|
||||
.PersistKeysToDbContext<ScadaBridgeDbContext>();
|
||||
|
||||
using var provider1 = services1.BuildServiceProvider();
|
||||
var protector1 = provider1.GetRequiredService<IDataProtectionProvider>()
|
||||
.CreateProtector("test-purpose");
|
||||
var protectedPayload = protector1.Protect("secret-data");
|
||||
|
||||
// Container 2: unprotect using the same DB (shared keys)
|
||||
var services2 = new ServiceCollection();
|
||||
services2.AddDbContext<ScadaBridgeDbContext>(opt => opt.UseSqlite(connectionString));
|
||||
services2.AddDataProtection()
|
||||
.SetApplicationName("ScadaBridge")
|
||||
.PersistKeysToDbContext<ScadaBridgeDbContext>();
|
||||
|
||||
using var provider2 = services2.BuildServiceProvider();
|
||||
var protector2 = provider2.GetRequiredService<IDataProtectionProvider>()
|
||||
.CreateProtector("test-purpose");
|
||||
var unprotected = protector2.Unprotect(protectedPayload);
|
||||
|
||||
Assert.Equal("secret-data", unprotected);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class DesignTimeDbContextFactoryTests : IDisposable
|
||||
{
|
||||
private const string EnvVar = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
|
||||
private readonly string? _originalEnv;
|
||||
|
||||
public DesignTimeDbContextFactoryTests()
|
||||
{
|
||||
_originalEnv = Environment.GetEnvironmentVariable(EnvVar);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(EnvVar, _originalEnv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDbContext_NoConnectionStringConfigured_ThrowsClearException()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-002: the factory must not fall back
|
||||
// to a hardcoded `sa`/password literal. With nothing configured it must fail loudly
|
||||
// with an actionable message instead of silently pointing tooling at a guessed DB.
|
||||
Environment.SetEnvironmentVariable(EnvVar, null);
|
||||
var factory = new DesignTimeDbContextFactory();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateDbContext(Array.Empty<string>()));
|
||||
|
||||
Assert.Contains("connection string", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
// The message must not leak / suggest a hardcoded `sa` credential.
|
||||
Assert.DoesNotContain("sa", ex.Message.Split(' '), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDbContext_ConnectionStringFromEnvironmentVariable_IsUsed()
|
||||
{
|
||||
// The design-time connection string may be supplied via an environment variable
|
||||
// rather than a source literal.
|
||||
Environment.SetEnvironmentVariable(EnvVar,
|
||||
"Server=localhost,1433;Database=ScadaBridge_Config;Trusted_Connection=True;TrustServerCertificate=True");
|
||||
var factory = new DesignTimeDbContextFactory();
|
||||
|
||||
using var context = factory.CreateDbContext(Array.Empty<string>());
|
||||
|
||||
Assert.NotNull(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesignTimeDbContextFactory_SourceContainsNoHardcodedSaCredential()
|
||||
{
|
||||
// Belt-and-braces: assert no `sa`/password literal exists in the compiled type's
|
||||
// behaviour by confirming the no-config path throws rather than connecting.
|
||||
Environment.SetEnvironmentVariable(EnvVar, null);
|
||||
var factory = new DesignTimeDbContextFactory();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateDbContext(Array.Empty<string>()));
|
||||
Assert.DoesNotContain("YourPassword", ex.Message);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for ConfigurationDatabase-013: a <see cref="ScadaBridgeDbContext"/>
|
||||
/// constructed without an explicit Data Protection provider (the single-argument
|
||||
/// constructor) must NOT silently encrypt secret columns with a throwaway ephemeral
|
||||
/// key — that would persist ciphertext that becomes permanently undecryptable on the
|
||||
/// next process restart, with no error. Writing a secret column on such a context
|
||||
/// must fail fast with a clear <see cref="InvalidOperationException"/> instead.
|
||||
/// </summary>
|
||||
public class EphemeralEncryptionFallbackTests
|
||||
{
|
||||
private static DbContextOptions<ScadaBridgeDbContext> SqliteOptions() =>
|
||||
new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
[Fact]
|
||||
public async Task SingleArgConstructor_WritingSecretColumn_FailsFast_DoesNotPersistThrowawayCiphertext()
|
||||
{
|
||||
// Single-argument constructor: no Data Protection provider supplied (the
|
||||
// design-time / schema-only path). Schema creation must still succeed.
|
||||
using var context = new ScadaBridgeDbContext(SqliteOptions());
|
||||
context.Database.OpenConnection();
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
// AuthConfiguration is an encrypted secret column. Persisting it without a real
|
||||
// key ring would produce undecryptable ciphertext; it must throw instead.
|
||||
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "ApiKey")
|
||||
{
|
||||
AuthConfiguration = "{\"apiKey\":\"live-secret\"}"
|
||||
};
|
||||
context.ExternalSystemDefinitions.Add(ext);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => context.SaveChangesAsync());
|
||||
Assert.Contains("Data Protection", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleArgConstructor_WritingNonSecretColumn_Succeeds()
|
||||
{
|
||||
// The schema-only / no-provider context must remain fully usable for entities
|
||||
// that have no encrypted secret columns — only secret writes are gated.
|
||||
using var context = new ScadaBridgeDbContext(SqliteOptions());
|
||||
context.Database.OpenConnection();
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "None");
|
||||
context.ExternalSystemDefinitions.Add(ext);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Assert.True(ext.Id > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderConstructor_WritingSecretColumn_StillSucceeds()
|
||||
{
|
||||
// Sanity check: the gating must not regress the supported runtime path where a
|
||||
// real Data Protection provider is supplied.
|
||||
using var context = new ScadaBridgeDbContext(SqliteOptions(), new EphemeralDataProtectionProvider());
|
||||
context.Database.OpenConnection();
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "ApiKey")
|
||||
{
|
||||
AuthConfiguration = "{\"apiKey\":\"live-secret\"}"
|
||||
};
|
||||
context.ExternalSystemDefinitions.Add(ext);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await context.ExternalSystemDefinitions.SingleAsync(e => e.Id == ext.Id);
|
||||
Assert.Equal("{\"apiKey\":\"live-secret\"}", loaded.AuthConfiguration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class InboundApiRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly CapturingLogger<InboundApiRepository> _logger = new();
|
||||
private readonly InboundApiRepository _repository;
|
||||
|
||||
public InboundApiRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new InboundApiRepository(_context, hasherAccessor: null, logger: _logger);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiKey_AndGetById_RoundTrips()
|
||||
{
|
||||
var key = new ApiKey("Key1", "secret-value-1") { IsEnabled = true };
|
||||
await _repository.AddApiKeyAsync(key);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetApiKeyByIdAsync(key.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Key1", loaded!.Name);
|
||||
|
||||
var byValue = await _repository.GetApiKeyByValueAsync("secret-value-1");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal(key.Id, byValue!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CD016_GetApiKeyByValue_UsesInjectedPepperedHasher_NotDefault()
|
||||
{
|
||||
// CD-016 regression: stored KeyHash is produced by a peppered hasher.
|
||||
// A repository whose lookup uses ApiKeyHasher.Default (the pre-fix
|
||||
// behaviour) would compute a different digest and return null. With the
|
||||
// pepper-aware hasherAccessor wired in, the lookup must round-trip.
|
||||
var peppered = new Commons.Types.InboundApi.ApiKeyHasher("a-strong-test-pepper-of-sufficient-length");
|
||||
var pepperedHash = peppered.Hash("secret-with-pepper");
|
||||
var key = ApiKey.FromHash("Peppered", pepperedHash);
|
||||
key.IsEnabled = true;
|
||||
|
||||
using var ctx = SqliteTestHelper.CreateInMemoryContext();
|
||||
var repo = new InboundApiRepository(ctx, hasherAccessor: () => peppered, logger: _logger);
|
||||
await repo.AddApiKeyAsync(key);
|
||||
await repo.SaveChangesAsync();
|
||||
|
||||
var byValue = await repo.GetApiKeyByValueAsync("secret-with-pepper");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal(key.Id, byValue!.Id);
|
||||
|
||||
// And: a repository wired with the Default (unpeppered) hasher MUST
|
||||
// NOT find the same key — proving the lookup actually uses the
|
||||
// injected hasher and the original bug shape.
|
||||
var defaultRepo = new InboundApiRepository(ctx,
|
||||
hasherAccessor: () => Commons.Types.InboundApi.ApiKeyHasher.Default,
|
||||
logger: _logger);
|
||||
var missByDefault = await defaultRepo.GetApiKeyByValueAsync("secret-with-pepper");
|
||||
Assert.Null(missByDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiMethod_AndGetByName_RoundTrips()
|
||||
{
|
||||
var method = new ApiMethod("DoThing", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetMethodByNameAsync("DoThing");
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(method.Id, loaded!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys()
|
||||
{
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
var k2 = new ApiKey("K2", "v2");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.AddApiKeyAsync(k2);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id}, {k2.Id}" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-008: a corrupt token (a name where an
|
||||
// integer id is expected) must not be dropped silently — the corruption must be
|
||||
// observable via a logged warning, while the valid ids still resolve.
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id},not-an-id" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Single(keys);
|
||||
Assert.Equal(k1.Id, keys[0].Id);
|
||||
Assert.Single(_logger.Warnings);
|
||||
Assert.Contains("not-an-id", _logger.Warnings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning()
|
||||
{
|
||||
var method = new ApiMethod("M", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Empty(keys);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteApiMethod_RemovesEntity()
|
||||
{
|
||||
var method = new ApiMethod("ToDelete", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteApiMethodAsync(method.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetApiMethodByIdAsync(method.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new InboundApiRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal ILogger that captures warning-level messages for assertions.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Warnings { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == LogLevel.Warning)
|
||||
{
|
||||
Warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D (#23 M6-T5) integration tests for
|
||||
/// <see cref="AuditLogPartitionMaintenance"/>. Uses the same
|
||||
/// <see cref="MsSqlMigrationFixture"/> as the AuditLog migration / repository
|
||||
/// tests so the ALTER PARTITION FUNCTION DDL runs against the actual seeded
|
||||
/// <c>pf_AuditLog_Month</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The migration seeds boundaries for every month in 2026 and 2027 (Jan 2026
|
||||
/// through Dec 2027). Tests pick a lookahead relative to the current
|
||||
/// max-boundary at test start (rather than a fixed-target date) so each test
|
||||
/// is robust against earlier tests in the class having added boundaries to
|
||||
/// the shared fixture DB. Tests run sequentially within the class via xunit's
|
||||
/// per-class collection serialisation.
|
||||
/// </remarks>
|
||||
public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AuditLogPartitionMaintenanceTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private ScadaBridgeDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString).Options);
|
||||
|
||||
private AuditLogPartitionMaintenance NewMaintenance(ScadaBridgeDbContext ctx) =>
|
||||
new(ctx, NullLogger<AuditLogPartitionMaintenance>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the lookahead-in-months required to fall strictly inside the
|
||||
/// already-covered boundary range. Picks something well below the
|
||||
/// distance from "now" to the current max — guaranteed not to need any
|
||||
/// new SPLIT.
|
||||
/// </summary>
|
||||
private static int LookaheadInsideExistingRange(DateTime max)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
// (max - now) in whole months, minus a 1-month safety margin so we
|
||||
// never accidentally hit the boundary horizon edge case.
|
||||
var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1;
|
||||
return Math.Max(1, months);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the lookahead-in-months required to add exactly
|
||||
/// <paramref name="extraBoundaries"/> new boundaries past the current max.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EnsureLookaheadAsync defines horizon =
|
||||
/// <c>NormalizeToFirstOfMonth(UtcNow) + lookaheadMonths</c>. The new
|
||||
/// boundaries it issues are first-of-month values strictly greater than
|
||||
/// max, up to and including horizon. So
|
||||
/// <c>lookaheadMonths = monthsBetween(NormalizeToFirstOfMonth(UtcNow), max) + extraBoundaries</c>
|
||||
/// is the exact value that lands horizon on <c>max + extraBoundaries</c>
|
||||
/// months.
|
||||
/// </remarks>
|
||||
private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries)
|
||||
{
|
||||
var nowFirstOfMonth = FirstOfNextMonth(DateTime.UtcNow);
|
||||
var monthsToMax = ((max.Year - nowFirstOfMonth.Year) * 12) + max.Month - nowFirstOfMonth.Month;
|
||||
return monthsToMax + extraBoundaries;
|
||||
}
|
||||
|
||||
private static DateTime FirstOfNextMonth(DateTime instant)
|
||||
{
|
||||
var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
return firstOfThisMonth.AddMonths(1);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_AlreadyHasFutureRange_NoSplit_ReturnsEmpty()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
var max = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(max);
|
||||
|
||||
// Pick a lookahead small enough that horizon (NormalizeToFirstOfMonth(now)
|
||||
// + lookahead) lands well INSIDE the already-covered range — no SPLIT
|
||||
// should fire.
|
||||
var lookahead = LookaheadInsideExistingRange(max.Value);
|
||||
|
||||
var added = await maintenance.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Empty(added);
|
||||
|
||||
// Sanity: the max boundary is unchanged after the no-op call.
|
||||
var maxAfter = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.Equal(max, maxAfter);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_NeedsOneMoreBoundary_Splits_Returns1Boundary()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
var maxBefore = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxBefore);
|
||||
|
||||
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1);
|
||||
var expectedAdded = maxBefore.Value.AddMonths(1);
|
||||
|
||||
var added = await maintenance.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Single(added);
|
||||
Assert.Equal(expectedAdded, added[0]);
|
||||
|
||||
var maxAfter = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.Equal(expectedAdded, maxAfter);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_NeedsThreeBoundaries_Splits_Returns3Boundaries()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
var maxBefore = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxBefore);
|
||||
|
||||
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 3);
|
||||
|
||||
var added = await maintenance.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Equal(3, added.Count);
|
||||
Assert.Equal(maxBefore.Value.AddMonths(1), added[0]);
|
||||
Assert.Equal(maxBefore.Value.AddMonths(2), added[1]);
|
||||
Assert.Equal(maxBefore.Value.AddMonths(3), added[2]);
|
||||
|
||||
var maxAfter = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month
|
||||
/// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient
|
||||
/// connection drop) now aborts the loop instead of leaving partition holes. This
|
||||
/// test pins that abort behaviour: with an interceptor that throws on the SECOND
|
||||
/// SPLIT, the call must propagate the exception AND the first SPLIT's boundary
|
||||
/// must already be persisted in <c>pf_AuditLog_Month</c> (visible to a fresh
|
||||
/// <see cref="AuditLogPartitionMaintenance"/> instance) — proof that the loop did
|
||||
/// commit boundary N before throwing, and that the next tick can resume from
|
||||
/// boundary N+1 at-least-once with no holes.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
|
||||
{
|
||||
// STM: CD-024-SecondSplitThrowsAbortsLoop marker.
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Baseline max-boundary observed via a clean context.
|
||||
await using var baselineCtx = CreateContext();
|
||||
var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxBefore);
|
||||
|
||||
var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3);
|
||||
var expectedFirst = maxBefore.Value.AddMonths(1);
|
||||
|
||||
// Build a fresh context with an interceptor that throws on the 2nd ALTER
|
||||
// PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through
|
||||
// ExecuteSqlRawAsync exactly as a SqlException would — the loop has no
|
||||
// try/catch (CD-019), so the exception propagates after the first SPLIT
|
||||
// has already committed.
|
||||
var interceptor = new SecondSplitThrowsInterceptor();
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
await using var ctx = new ScadaBridgeDbContext(options);
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => maintenance.EnsureLookaheadAsync(lookahead));
|
||||
|
||||
// Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran
|
||||
// before the interceptor's throw: split #1 committed, split #2 threw,
|
||||
// split #3 was never attempted.
|
||||
Assert.Equal(1, interceptor.SuccessfulSplits);
|
||||
|
||||
// And verify the first boundary IS now persisted — the loop aborted but
|
||||
// boundary N is durable so the next tick resumes from N+1 (no holes).
|
||||
await using var verifyCtx = CreateContext();
|
||||
var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync();
|
||||
Assert.Equal(expectedFirst, maxAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core command interceptor: lets the first <c>ALTER PARTITION FUNCTION
|
||||
/// pf_AuditLog_Month() SPLIT RANGE</c> through and throws <see cref="InvalidOperationException"/>
|
||||
/// on the second one. Threads through synchronous + async + scalar + reader
|
||||
/// entry-points because <c>ExecuteSqlRawAsync</c> routes through the
|
||||
/// non-query async path but other code paths still go through the same
|
||||
/// interceptor pipeline. <see cref="SuccessfulSplits"/> counts the splits
|
||||
/// that were allowed to run so the test can pin the abort-after-one
|
||||
/// behaviour.
|
||||
/// </summary>
|
||||
private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
|
||||
{
|
||||
public int SuccessfulSplits { get; private set; }
|
||||
|
||||
private bool IsTargetSplit(DbCommand command) =>
|
||||
command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase)
|
||||
&& command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public override InterceptionResult<int> NonQueryExecuting(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<int> result)
|
||||
{
|
||||
ThrowIfSecondSplit(command);
|
||||
return base.NonQueryExecuting(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfSecondSplit(command);
|
||||
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
public override int NonQueryExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result)
|
||||
{
|
||||
if (IsTargetSplit(command))
|
||||
{
|
||||
SuccessfulSplits++;
|
||||
}
|
||||
return base.NonQueryExecuted(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<int> NonQueryExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsTargetSplit(command))
|
||||
{
|
||||
SuccessfulSplits++;
|
||||
}
|
||||
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
private void ThrowIfSecondSplit(DbCommand command)
|
||||
{
|
||||
if (!IsTargetSplit(command))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow the first SPLIT through; throw on the second so the loop's
|
||||
// post-CD-019 "let it propagate" behaviour can be asserted.
|
||||
if (SuccessfulSplits >= 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx1 = CreateContext();
|
||||
var m1 = NewMaintenance(ctx1);
|
||||
|
||||
var maxStart = await m1.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxStart);
|
||||
|
||||
// First call: add one boundary.
|
||||
var lookahead = LookaheadForExtraBoundaries(maxStart.Value, extraBoundaries: 1);
|
||||
var firstAdded = await m1.EnsureLookaheadAsync(lookahead);
|
||||
Assert.Single(firstAdded);
|
||||
|
||||
// Second call: the boundary just added is now part of pf_AuditLog_Month,
|
||||
// so the same lookahead value should be a no-op — no exception, no
|
||||
// duplicate SPLIT.
|
||||
await using var ctx2 = CreateContext();
|
||||
var m2 = NewMaintenance(ctx2);
|
||||
var secondAdded = await m2.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Empty(secondAdded);
|
||||
|
||||
// The max boundary is unchanged across the second call.
|
||||
var maxAfter = await m2.GetMaxBoundaryAsync();
|
||||
Assert.Equal(firstAdded[0], maxAfter);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// SourceNode-stamping (#23) integration tests for the
|
||||
/// <c>AddAuditLogSourceNode</c> migration: applies the EF migrations to a
|
||||
/// freshly-created MSSQL test database on the running infra/mssql container and
|
||||
/// asserts that the central <c>AuditLog</c> table carries the new
|
||||
/// <c>SourceNode varchar(64) NULL</c> column AND a partition-aligned
|
||||
/// <c>IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)</c> composite index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the <c>AddAuditLogParentExecutionId</c> shape: column is an additive
|
||||
/// metadata-only <c>ALTER TABLE … ADD</c>; the new index is created via raw SQL
|
||||
/// so it lives on <c>ps_AuditLog_Month(OccurredAtUtc)</c> like every other
|
||||
/// <c>IX_AuditLog_*</c> index, preserving the partition-switch purge path.
|
||||
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c>
|
||||
/// so the runner reports them as Skipped (not Passed) when MSSQL is unreachable.
|
||||
/// The fixture applies the migrations once at construction time.
|
||||
/// </remarks>
|
||||
public class AddAuditLogSourceNodeMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AddAuditLogSourceNodeMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_AddsSourceNodeColumn_ToAuditLog()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var present = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode' " +
|
||||
"AND TABLE_SCHEMA = 'dbo';");
|
||||
Assert.Equal(1, present);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SourceNodeColumn_IsNullableVarchar64()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// varchar (ASCII), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.)
|
||||
// and design doc fixes the column at varchar(64). Catches an EF default to
|
||||
// nvarchar if the migration ever drops `unicode: false`.
|
||||
var dataType = await ScalarAsync<string?>(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal("varchar", dataType);
|
||||
|
||||
var maxLength = await ScalarAsync<int>(
|
||||
"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal(64, maxLength);
|
||||
|
||||
var isNullable = await ScalarAsync<string?>(
|
||||
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'AuditLog' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal("YES", isNullable);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesIxAuditLogNodeOccurredIndex()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Locked index name from the design doc / CLAUDE.md.
|
||||
var indexCount = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred';");
|
||||
Assert.Equal(1, indexCount);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task IxAuditLogNodeOccurred_HasExpectedKeyColumnsInOrder()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Key columns in order: SourceNode, OccurredAtUtc. sys.index_columns.key_ordinal
|
||||
// gives the position in the index key (1-based); is_included_column = 0 means
|
||||
// it's part of the key, not an INCLUDE.
|
||||
var keyColumns = new List<(int Ordinal, string Name)>();
|
||||
|
||||
await using (var conn = _fixture.OpenConnection())
|
||||
await using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT ic.key_ordinal, c.name " +
|
||||
"FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"INNER JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id " +
|
||||
"INNER JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id " +
|
||||
"WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred' " +
|
||||
" AND ic.is_included_column = 0 " +
|
||||
"ORDER BY ic.key_ordinal;";
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
keyColumns.Add((reader.GetByte(0), reader.GetString(1)));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(2, keyColumns.Count);
|
||||
Assert.Equal("SourceNode", keyColumns[0].Name);
|
||||
Assert.Equal("OccurredAtUtc", keyColumns[1].Name);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task IxAuditLogNodeOccurred_LivesOnPsAuditLogMonth_PartitionScheme()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Partition-aligned indexes are required so the AuditLog partition-switch
|
||||
// purge keeps working. Every other IX_AuditLog_* index lives on
|
||||
// ps_AuditLog_Month(OccurredAtUtc); the new one must too.
|
||||
var schemeName = await ScalarAsync<string?>(
|
||||
"SELECT ps.name FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " +
|
||||
"WHERE o.name = 'AuditLog' AND i.name = 'IX_AuditLog_Node_Occurred';");
|
||||
|
||||
Assert.Equal("ps_AuditLog_Month", schemeName);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle C (#23 M1) integration tests: applies the EF migrations to a
|
||||
/// freshly-created MSSQL test database on the running infra/mssql container
|
||||
/// and asserts that the AddAuditLogTable migration produced the expected
|
||||
/// partition function, partition scheme, partition-aligned table, named
|
||||
/// indexes, and DB roles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tests use <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot(...)</c> from
|
||||
/// the Xunit.SkippableFact package so the runner reports them as Skipped (not
|
||||
/// Passed) when MSSQL is unreachable. xunit 2.9.x does not ship a native
|
||||
/// <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> — those land in xunit v3 — so
|
||||
/// SkippableFact is the canonical equivalent for this project. The fixture
|
||||
/// applies the migration once at construction time.
|
||||
/// </remarks>
|
||||
public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesAuditLogTable()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var exists = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
|
||||
"WHERE TABLE_NAME = 'AuditLog' AND TABLE_SCHEMA = 'dbo';");
|
||||
|
||||
Assert.Equal(1, exists);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesPartitionFunction_pf_AuditLog_Month()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var functionExists = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month';");
|
||||
Assert.Equal(1, functionExists);
|
||||
|
||||
// Specification (alog.md §4 / Bundle C plan): 24 monthly boundaries
|
||||
// covering 2026-01-01 through 2027-12-01 UTC.
|
||||
var boundaryCount = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.partition_range_values rv " +
|
||||
"INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id " +
|
||||
"WHERE pf.name = 'pf_AuditLog_Month';");
|
||||
Assert.True(boundaryCount >= 24,
|
||||
$"Expected at least 24 monthly boundaries on pf_AuditLog_Month; got {boundaryCount}.");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesPartitionScheme_ps_AuditLog_Month()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var schemeExists = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month';");
|
||||
Assert.Equal(1, schemeExists);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_TableIsPartitionAligned()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// The clustered (PK) index on AuditLog must live on the ps_AuditLog_Month
|
||||
// partition scheme; sys.indexes.data_space_id points at the scheme.
|
||||
var schemeName = await ScalarAsync<string?>(
|
||||
"SELECT ps.name FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " +
|
||||
"WHERE o.name = 'AuditLog' AND i.index_id = 1;");
|
||||
|
||||
Assert.Equal("ps_AuditLog_Month", schemeName);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesFiveNamedIndexes()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var expected = new[]
|
||||
{
|
||||
"IX_AuditLog_OccurredAtUtc",
|
||||
"IX_AuditLog_Site_Occurred",
|
||||
"IX_AuditLog_CorrelationId",
|
||||
"IX_AuditLog_Channel_Status_Occurred",
|
||||
"IX_AuditLog_Target_Occurred",
|
||||
};
|
||||
|
||||
foreach (var indexName in expected)
|
||||
{
|
||||
var count = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
$"WHERE o.name = 'AuditLog' AND i.name = '{indexName}';");
|
||||
Assert.True(count == 1, $"Expected index '{indexName}' to exist on AuditLog; found {count}.");
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesAuditWriterRole_WithExpectedGrants()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var roleExists = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_principals " +
|
||||
"WHERE name = 'scadabridge_audit_writer' AND type = 'R';");
|
||||
Assert.Equal(1, roleExists);
|
||||
|
||||
// GRANT INSERT + GRANT SELECT must be present (G state = grant).
|
||||
var insertGranted = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||
"WHERE pr.name = 'scadabridge_audit_writer' AND o.name = 'AuditLog' " +
|
||||
" AND p.permission_name = 'INSERT' AND p.state IN ('G','W');");
|
||||
Assert.Equal(1, insertGranted);
|
||||
|
||||
var selectGranted = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||
"WHERE pr.name = 'scadabridge_audit_writer' AND o.name = 'AuditLog' " +
|
||||
" AND p.permission_name = 'SELECT' AND p.state IN ('G','W');");
|
||||
Assert.Equal(1, selectGranted);
|
||||
|
||||
// UPDATE / DELETE must NOT be granted — and DENY (state = 'D') is even
|
||||
// stronger. Treat presence of GRANT (state 'G' or 'W') as the failure.
|
||||
var updateGranted = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||
"WHERE pr.name = 'scadabridge_audit_writer' AND o.name = 'AuditLog' " +
|
||||
" AND p.permission_name = 'UPDATE' AND p.state IN ('G','W');");
|
||||
Assert.Equal(0, updateGranted);
|
||||
|
||||
var deleteGranted = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||
"WHERE pr.name = 'scadabridge_audit_writer' AND o.name = 'AuditLog' " +
|
||||
" AND p.permission_name = 'DELETE' AND p.state IN ('G','W');");
|
||||
Assert.Equal(0, deleteGranted);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesAuditPurgerRole_WithExpectedGrants()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var roleExists = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_principals " +
|
||||
"WHERE name = 'scadabridge_audit_purger' AND type = 'R';");
|
||||
Assert.Equal(1, roleExists);
|
||||
|
||||
// SELECT on AuditLog.
|
||||
var selectGranted = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||
"WHERE pr.name = 'scadabridge_audit_purger' AND o.name = 'AuditLog' " +
|
||||
" AND p.permission_name = 'SELECT' AND p.state IN ('G','W');");
|
||||
Assert.Equal(1, selectGranted);
|
||||
|
||||
// ALTER on SCHEMA::dbo (class 3 = SCHEMA).
|
||||
var alterSchema = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||
"INNER JOIN sys.schemas s ON p.major_id = s.schema_id " +
|
||||
"WHERE pr.name = 'scadabridge_audit_purger' AND s.name = 'dbo' " +
|
||||
" AND p.class = 3 AND p.permission_name = 'ALTER' AND p.state IN ('G','W');");
|
||||
Assert.Equal(1, alterSchema);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AuditWriterRole_CannotUpdateAuditLog()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Set up a dedicated user mapped to scadabridge_audit_writer, then EXECUTE AS
|
||||
// and attempt UPDATE — DENY UPDATE on the role must reject the statement.
|
||||
// Use a guid-suffixed user name so reruns in the same fixture don't collide.
|
||||
var testUser = $"audit_writer_smoke_{Guid.NewGuid():N}".Substring(0, 32);
|
||||
|
||||
await using (var setup = new SqlConnection(_fixture.ConnectionString))
|
||||
{
|
||||
await setup.OpenAsync();
|
||||
await using var setupCmd = setup.CreateCommand();
|
||||
setupCmd.CommandText =
|
||||
$"CREATE USER [{testUser}] WITHOUT LOGIN; " +
|
||||
$"ALTER ROLE scadabridge_audit_writer ADD MEMBER [{testUser}];";
|
||||
await setupCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
|
||||
{
|
||||
await using var conn = new SqlConnection(_fixture.ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// WHERE 1=0 guarantees no rows are touched even if the permission check
|
||||
// somehow passes — the test asserts the engine rejects the statement
|
||||
// at permission-check time, not via a side effect on data.
|
||||
cmd.CommandText =
|
||||
$"EXECUTE AS USER = '{testUser}'; " +
|
||||
$"UPDATE dbo.AuditLog SET Status = 'X' WHERE 1 = 0; " +
|
||||
$"REVERT;";
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
});
|
||||
|
||||
// SQL Server permission-denied errors carry number 229 (e.g. "The UPDATE
|
||||
// permission was denied"). Assert the message mentions permission rather
|
||||
// than pinning to the exact code, in case the engine version drifts.
|
||||
Assert.Contains("permission", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 5) integration test for the
|
||||
/// <c>AddNotificationOriginExecutionId</c> migration: applies the EF migrations
|
||||
/// to a freshly-created MSSQL test database on the running infra/mssql container
|
||||
/// and asserts that the <c>Notifications</c> table carries the new
|
||||
/// <c>OriginExecutionId</c> column as a nullable <c>uniqueidentifier</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
|
||||
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
|
||||
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
|
||||
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
|
||||
/// fixture applies the migrations once at construction time.
|
||||
/// </remarks>
|
||||
public class AddNotificationOriginExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AddNotificationOriginExecutionIdMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_AddsOriginExecutionIdColumn_ToNotifications()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var present = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId' " +
|
||||
"AND TABLE_SCHEMA = 'dbo';");
|
||||
Assert.Equal(1, present);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task OriginExecutionIdColumn_IsNullableUniqueIdentifier()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var dataType = await ScalarAsync<string?>(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
|
||||
Assert.Equal("uniqueidentifier", dataType);
|
||||
|
||||
var isNullable = await ScalarAsync<string?>(
|
||||
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
|
||||
Assert.Equal("YES", isNullable);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log ParentExecutionId integration test for the
|
||||
/// <c>AddNotificationOriginParentExecutionId</c> migration: applies the EF
|
||||
/// migrations to a freshly-created MSSQL test database on the running
|
||||
/// infra/mssql container and asserts that the <c>Notifications</c> table carries
|
||||
/// the new <c>OriginParentExecutionId</c> column as a nullable
|
||||
/// <c>uniqueidentifier</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
|
||||
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
|
||||
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
|
||||
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
|
||||
/// fixture applies the migrations once at construction time.
|
||||
/// </remarks>
|
||||
public class AddNotificationOriginParentExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AddNotificationOriginParentExecutionIdMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_AddsOriginParentExecutionIdColumn_ToNotifications()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var present = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId' " +
|
||||
"AND TABLE_SCHEMA = 'dbo';");
|
||||
Assert.Equal(1, present);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task OriginParentExecutionIdColumn_IsNullableUniqueIdentifier()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var dataType = await ScalarAsync<string?>(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
|
||||
Assert.Equal("uniqueidentifier", dataType);
|
||||
|
||||
var isNullable = await ScalarAsync<string?>(
|
||||
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
|
||||
Assert.Equal("YES", isNullable);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// SourceNode-stamping (#23) integration tests for the
|
||||
/// <c>AddNotificationSourceNode</c> migration: applies the EF migrations to a
|
||||
/// freshly-created MSSQL test database on the running infra/mssql container and
|
||||
/// asserts that the central <c>Notifications</c> table carries the new
|
||||
/// <c>SourceNode varchar(64) NULL</c> column. No index — Notification Outbox KPIs
|
||||
/// are per-site, not per-node, so the column is never a query predicate on this
|
||||
/// table; it's only echoed onto NotifyDeliver audit rows (#23) for cross-row
|
||||
/// correlation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Notifications</c> is non-partitioned (operational state, not audit), so this
|
||||
/// is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
|
||||
/// </remarks>
|
||||
public class AddNotificationSourceNodeMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AddNotificationSourceNodeMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_AddsSourceNodeColumn_ToNotifications()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var present = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode' " +
|
||||
"AND TABLE_SCHEMA = 'dbo';");
|
||||
Assert.Equal(1, present);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SourceNodeColumn_IsNullableVarchar64()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// varchar(64), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.).
|
||||
var dataType = await ScalarAsync<string?>(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal("varchar", dataType);
|
||||
|
||||
var maxLength = await ScalarAsync<int>(
|
||||
"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal(64, maxLength);
|
||||
|
||||
var isNullable = await ScalarAsync<string?>(
|
||||
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal("YES", isNullable);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// SourceNode-stamping (#23) integration tests for the
|
||||
/// <c>AddSiteCallSourceNode</c> migration: applies the EF migrations to a
|
||||
/// freshly-created MSSQL test database on the running infra/mssql container and
|
||||
/// asserts that the central <c>SiteCalls</c> table carries the new
|
||||
/// <c>SourceNode varchar(64) NULL</c> column. No index — Site Call Audit KPIs
|
||||
/// are per-site, not per-node, on this table; <c>SourceNode</c> is operational
|
||||
/// metadata, not a query predicate here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>SiteCalls</c> is non-partitioned (operational state, not audit), so this
|
||||
/// is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
|
||||
/// </remarks>
|
||||
public class AddSiteCallSourceNodeMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AddSiteCallSourceNodeMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_AddsSourceNodeColumn_ToSiteCalls()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var present = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode' " +
|
||||
"AND TABLE_SCHEMA = 'dbo';");
|
||||
Assert.Equal(1, present);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task SourceNodeColumn_IsNullableVarchar64()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// varchar(64), not nvarchar — SourceNode is ASCII (`node-a`, `central-a` etc.).
|
||||
var dataType = await ScalarAsync<string?>(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal("varchar", dataType);
|
||||
|
||||
var maxLength = await ScalarAsync<int>(
|
||||
"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal(64, maxLength);
|
||||
|
||||
var isNullable = await ScalarAsync<string?>(
|
||||
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = 'SourceNode';");
|
||||
Assert.Equal("YES", isNullable);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B2 (#22, #23 M3) integration tests for the <c>AddSiteCallsTable</c>
|
||||
/// migration: applies the EF migrations to a freshly-created MSSQL test database
|
||||
/// on the running infra/mssql container and asserts that the resulting
|
||||
/// <c>SiteCalls</c> table carries the expected columns, primary key, and the
|
||||
/// two named operational indexes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <c>AddAuditLogTable</c>, the SiteCalls table is operational (mutable)
|
||||
/// state — no partition function, no partition scheme, no DB-role restriction.
|
||||
/// Standard <c>[PRIMARY]</c> filegroup. Tests pair <see cref="SkippableFactAttribute"/>
|
||||
/// with <c>Skip.IfNot(...)</c> so the runner reports them as Skipped (not Passed)
|
||||
/// when MSSQL is unreachable. The fixture applies the migration once at
|
||||
/// construction time.
|
||||
/// </remarks>
|
||||
public class AddSiteCallsTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AddSiteCallsTableMigrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesSiteCallsTable_WithExpectedColumns()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var exists = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
|
||||
"WHERE TABLE_NAME = 'SiteCalls' AND TABLE_SCHEMA = 'dbo';");
|
||||
Assert.Equal(1, exists);
|
||||
|
||||
// Every required column from SiteCall + IngestedAtUtc. We don't pin types
|
||||
// here because EF's CreateTable layer already encodes them; the
|
||||
// entity-config tests cover length / unicode / nullability for the
|
||||
// value-converted PK column. Just confirm the schema has all twelve.
|
||||
var expectedColumns = new[]
|
||||
{
|
||||
"TrackedOperationId",
|
||||
"Channel",
|
||||
"Target",
|
||||
"SourceSite",
|
||||
"Status",
|
||||
"RetryCount",
|
||||
"LastError",
|
||||
"HttpStatus",
|
||||
"CreatedAtUtc",
|
||||
"UpdatedAtUtc",
|
||||
"TerminalAtUtc",
|
||||
"IngestedAtUtc",
|
||||
};
|
||||
|
||||
foreach (var column in expectedColumns)
|
||||
{
|
||||
var present = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
$"WHERE TABLE_NAME = 'SiteCalls' AND COLUMN_NAME = '{column}';");
|
||||
Assert.True(present == 1, $"Expected SiteCalls.{column} to exist; found {present}.");
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesPK_OnTrackedOperationId()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Walk sys.indexes for the table's clustered PK index and confirm its
|
||||
// single key column is TrackedOperationId. SiteCalls is non-partitioned
|
||||
// so the PK is a simple single-column clustered index.
|
||||
var pkColumn = await ScalarAsync<string?>(
|
||||
"SELECT c.name FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id " +
|
||||
"INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id " +
|
||||
"WHERE o.name = 'SiteCalls' AND i.is_primary_key = 1;");
|
||||
|
||||
Assert.Equal("TrackedOperationId", pkColumn);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesIndex_Source_Created()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var count = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"WHERE o.name = 'SiteCalls' AND i.name = 'IX_SiteCalls_Source_Created';");
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task AppliesMigration_CreatesIndex_Status_Updated()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var count = await ScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||
"WHERE o.name = 'SiteCalls' AND i.name = 'IX_SiteCalls_Status_Updated';");
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Per-test-class MSSQL fixture for the Bundle C integration tests (#23 M1).
|
||||
///
|
||||
/// Creates a fresh, uniquely-named test database on the running infra/mssql
|
||||
/// container, applies the EF migrations against it, and drops it on dispose.
|
||||
/// When MSSQL is not reachable (CI without the container), <see cref="Available"/>
|
||||
/// is set to false and <see cref="SkipReason"/> describes why — tests pair
|
||||
/// <c>[SkippableFact]</c> with <c>Skip.IfNot(_fixture.Available, _fixture.SkipReason)</c>
|
||||
/// so the runner reports them as Skipped (not silently Passed).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// xunit 2.9.x has no native <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> (those
|
||||
/// are v3); the project uses the Xunit.SkippableFact package as the canonical
|
||||
/// equivalent. The fixture attempts connect + create-db + migrate once at
|
||||
/// construct time. The Connect Timeout=3 in <see cref="DefaultAdminConnectionString"/>
|
||||
/// makes the fixture fail fast in a no-container environment (under ~5s total)
|
||||
/// instead of hanging 30s on SqlClient's default. Only connect-failure exceptions
|
||||
/// (SqlException, plus the InvalidOperationException SqlClient raises from
|
||||
/// OpenAsync) flip Available to false — every other exception bubbles up so a
|
||||
/// real bug is not silently swallowed.
|
||||
/// </remarks>
|
||||
public sealed class MsSqlMigrationFixture : IDisposable
|
||||
{
|
||||
// Same credentials infra/mssql/setup.sql + docker-compose use. Not a committed
|
||||
// production secret — this is a local dev container connection string.
|
||||
// Connect Timeout=3 makes the fixture fail fast (~3s) in a no-container
|
||||
// environment rather than hanging on SqlClient's default 30s connect timeout.
|
||||
private const string DefaultAdminConnectionString =
|
||||
"Server=localhost,1433;User Id=sa;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=3";
|
||||
|
||||
private const string AdminEnvVar = "SCADALINK_MSSQL_TEST_CONN";
|
||||
|
||||
public string DatabaseName { get; }
|
||||
|
||||
public string ConnectionString { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the MSSQL container was reachable at fixture construction
|
||||
/// time AND the per-fixture test database was successfully created. When
|
||||
/// false, the integration tests using this fixture must early-return.
|
||||
/// </summary>
|
||||
public bool Available { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Populated when <see cref="Available"/> is false; describes why the
|
||||
/// fixture chose to skip (env var unset, connect failed, etc.).
|
||||
/// </summary>
|
||||
public string SkipReason { get; }
|
||||
|
||||
private readonly string _adminConnectionString;
|
||||
|
||||
public MsSqlMigrationFixture()
|
||||
{
|
||||
// Short, lowercase guid suffix keeps the database identifier under SQL Server's
|
||||
// 128-char limit and safe for raw concatenation (no quoting required).
|
||||
DatabaseName = $"ScadaBridgeAuditMigTest_{Guid.NewGuid():N}".Substring(0, 38);
|
||||
|
||||
// Env var lets CI / power users override the admin endpoint; absent
|
||||
// defaults to the local docker dev container's sa connection.
|
||||
var fromEnv = Environment.GetEnvironmentVariable(AdminEnvVar);
|
||||
_adminConnectionString = string.IsNullOrWhiteSpace(fromEnv)
|
||||
? DefaultAdminConnectionString
|
||||
: fromEnv;
|
||||
|
||||
// Step 1: open the admin connection. This is the only step that may
|
||||
// legitimately fail when MSSQL is absent; SqlException + the rare
|
||||
// InvalidOperationException from OpenAsync are the connect-failure
|
||||
// surfaces we tolerate. Everything else (CREATE DATABASE, MigrateAsync)
|
||||
// is treated as a hard fixture failure once we *have* a connection.
|
||||
try
|
||||
{
|
||||
using var connection = new SqlConnection(_adminConnectionString);
|
||||
try
|
||||
{
|
||||
connection.Open();
|
||||
}
|
||||
catch (SqlException ex)
|
||||
{
|
||||
ConnectionString = string.Empty;
|
||||
Available = false;
|
||||
SkipReason = $"MSSQL unavailable (connect failed: SqlException {ex.Number}: {ex.Message})";
|
||||
return;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ConnectionString = string.Empty;
|
||||
Available = false;
|
||||
SkipReason = $"MSSQL unavailable (OpenAsync threw: {ex.Message})";
|
||||
return;
|
||||
}
|
||||
|
||||
using (var createCmd = connection.CreateCommand())
|
||||
{
|
||||
createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];";
|
||||
createCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName);
|
||||
|
||||
// Apply the EF migrations once at fixture construction so each test
|
||||
// can read from a fully-migrated database without per-test setup.
|
||||
// Failures here are real bugs — let them bubble.
|
||||
ApplyMigrationsCore(ConnectionString, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Available = true;
|
||||
SkipReason = string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup if we created the database but failed before
|
||||
// setting Available — otherwise Dispose() would skip the drop.
|
||||
TryDropOrphanDatabase();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDropOrphanDatabase()
|
||||
{
|
||||
if (string.IsNullOrEmpty(ConnectionString))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SqlConnection.ClearAllPools();
|
||||
using var connection = new SqlConnection(_adminConnectionString);
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText =
|
||||
$"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
|
||||
$"BEGIN " +
|
||||
$" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
|
||||
$" DROP DATABASE [{DatabaseName}]; " +
|
||||
$"END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — orphan databases carry a random guid suffix.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the EF migrations to the per-fixture test database via a freshly
|
||||
/// constructed <see cref="ScadaBridgeDbContext"/> pointed at it. Uses the
|
||||
/// schema-only single-argument constructor — the AuditLog migration does
|
||||
/// not write secret-bearing columns at apply time. Called once from the
|
||||
/// constructor; tests do not invoke this directly.
|
||||
/// </summary>
|
||||
private static async Task ApplyMigrationsCore(string connectionString, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(connectionString)
|
||||
.Options;
|
||||
|
||||
await using var context = new ScadaBridgeDbContext(options);
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience for opening a fresh <see cref="SqlConnection"/> to the test
|
||||
/// database. Caller is responsible for disposal.
|
||||
/// </summary>
|
||||
public SqlConnection OpenConnection()
|
||||
{
|
||||
ThrowIfUnavailable();
|
||||
|
||||
var connection = new SqlConnection(ConnectionString);
|
||||
connection.Open();
|
||||
return connection;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Best-effort drop — never let a teardown failure pollute later runs.
|
||||
// SINGLE_USER WITH ROLLBACK IMMEDIATE detaches lingering pooled connections
|
||||
// so the DROP DATABASE doesn't fail with "database is in use".
|
||||
try
|
||||
{
|
||||
// Connection-pool cleanup is necessary because EF's MigrateAsync leaves
|
||||
// pooled connections behind; SqlConnection.ClearAllPools() forces them
|
||||
// closed so the SINGLE_USER + DROP sequence below can complete.
|
||||
SqlConnection.ClearAllPools();
|
||||
|
||||
using var connection = new SqlConnection(_adminConnectionString);
|
||||
connection.Open();
|
||||
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText =
|
||||
$"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
|
||||
$"BEGIN " +
|
||||
$" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
|
||||
$" DROP DATABASE [{DatabaseName}]; " +
|
||||
$"END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — the database name carries a random guid suffix so a
|
||||
// stranded test database does not collide with future runs.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws an <see cref="InvalidOperationException"/> when invoked on an
|
||||
/// unavailable fixture; tests should branch on <see cref="Available"/>
|
||||
/// before reaching this code path.
|
||||
/// </summary>
|
||||
private void ThrowIfUnavailable()
|
||||
{
|
||||
if (!Available)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MsSqlMigrationFixture is not Available: {SkipReason}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildPerDbConnectionString(string adminConnectionString, string databaseName)
|
||||
{
|
||||
var builder = new SqlConnectionStringBuilder(adminConnectionString)
|
||||
{
|
||||
InitialCatalog = databaseName,
|
||||
};
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
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;
|
||||
|
||||
// Coverage for per-site KPI aggregation in the Notification Outbox repository
|
||||
// (Task 2 of the notifications-nav-group feature).
|
||||
public class NotificationOutboxRepositoryPerSiteKpiTests
|
||||
{
|
||||
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
|
||||
|
||||
private static Notification NewNotification(
|
||||
string sourceSiteId,
|
||||
NotificationStatus status,
|
||||
DateTimeOffset createdAt,
|
||||
DateTimeOffset? deliveredAt = null)
|
||||
{
|
||||
return new Notification(
|
||||
Guid.NewGuid().ToString(), NotificationType.Email, "Ops List", "Subject", "Body", sourceSiteId)
|
||||
{
|
||||
Status = status,
|
||||
CreatedAt = createdAt,
|
||||
DeliveredAt = deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// plant-a: 1 pending (stuck, created 20m ago), 1 parked
|
||||
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, createdAt: now.AddMinutes(-20)));
|
||||
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked, createdAt: now.AddMinutes(-5)));
|
||||
// plant-b: 1 delivered in-window, 1 pending (fresh)
|
||||
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered, createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2)));
|
||||
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending, createdAt: now.AddMinutes(-1)));
|
||||
// plant-c: 2 non-terminal rows of clearly different ages — pending 90m ago,
|
||||
// retrying 40m ago. Both predate the 10m stuck cutoff. Exercises the
|
||||
// in-memory g.Min(CreatedAt) oldest-age reduction and the Retrying branch
|
||||
// of the QueueDepth/StuckCount predicates.
|
||||
ctx.Notifications.Add(NewNotification("plant-c", NotificationStatus.Pending, createdAt: now.AddMinutes(-90)));
|
||||
ctx.Notifications.Add(NewNotification("plant-c", NotificationStatus.Retrying, createdAt: now.AddMinutes(-40)));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var repo = new NotificationOutboxRepository(ctx);
|
||||
var result = await repo.ComputePerSiteKpisAsync(
|
||||
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
|
||||
|
||||
var a = result.Single(s => s.SourceSiteId == "plant-a");
|
||||
Assert.Equal(1, a.QueueDepth);
|
||||
Assert.Equal(1, a.StuckCount);
|
||||
Assert.Equal(1, a.ParkedCount);
|
||||
Assert.Equal(0, a.DeliveredLastInterval);
|
||||
Assert.NotNull(a.OldestPendingAge);
|
||||
|
||||
var b = result.Single(s => s.SourceSiteId == "plant-b");
|
||||
Assert.Equal(1, b.QueueDepth);
|
||||
Assert.Equal(0, b.StuckCount);
|
||||
Assert.Equal(1, b.DeliveredLastInterval);
|
||||
|
||||
// plant-c: both the Pending and Retrying rows count toward QueueDepth;
|
||||
// both predate the stuck cutoff so both are stuck. OldestPendingAge must
|
||||
// reflect the older (90m) row, not the 10m Retrying one.
|
||||
var c = result.Single(s => s.SourceSiteId == "plant-c");
|
||||
Assert.Equal(2, c.QueueDepth);
|
||||
Assert.Equal(2, c.StuckCount);
|
||||
Assert.Equal(0, c.ParkedCount);
|
||||
Assert.NotNull(c.OldestPendingAge);
|
||||
// Tolerant lower bound to absorb clock skew between seed time and the
|
||||
// `now` captured inside ComputePerSiteKpisAsync.
|
||||
Assert.True(c.OldestPendingAge >= TimeSpan.FromMinutes(85),
|
||||
$"expected OldestPendingAge >= 85m, got {c.OldestPendingAge}");
|
||||
Assert.True(c.OldestPendingAge < TimeSpan.FromMinutes(95),
|
||||
$"expected OldestPendingAge < 95m, got {c.OldestPendingAge}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications()
|
||||
{
|
||||
await using var ctx = NewContext();
|
||||
var repo = new NotificationOutboxRepository(ctx);
|
||||
var result = await repo.ComputePerSiteKpisAsync(
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30));
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
+1115
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// CD-015 race-fix integration tests for
|
||||
/// <see cref="NotificationOutboxRepository.InsertIfNotExistsAsync"/>. The method
|
||||
/// is raw-SQL (<c>IF NOT EXISTS … INSERT</c>) matching the AuditLog and SiteCalls
|
||||
/// idempotent-insert pattern; it must execute against a real SQL Server schema,
|
||||
/// so this class uses <see cref="MsSqlMigrationFixture"/> rather than the SQLite
|
||||
/// in-memory provider used by <see cref="RepositoryCoverageTests"/>.
|
||||
/// </summary>
|
||||
public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public NotificationOutboxRepositoryIntegrationTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task InsertIfNotExistsAsync_FreshId_InsertsAndReturnsTrue()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await using var context = CreateContext();
|
||||
var repo = new NotificationOutboxRepository(context);
|
||||
|
||||
var inserted = await repo.InsertIfNotExistsAsync(MakeNotification(id));
|
||||
|
||||
Assert.True(inserted);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Notifications.FindAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Subject", loaded!.Subject);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task InsertIfNotExistsAsync_DuplicateId_ReturnsFalseAndLeavesExistingRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new NotificationOutboxRepository(context);
|
||||
await repo.InsertIfNotExistsAsync(MakeNotification(id, subject: "Original"));
|
||||
}
|
||||
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new NotificationOutboxRepository(context);
|
||||
var inserted = await repo.InsertIfNotExistsAsync(MakeNotification(id, subject: "Changed"));
|
||||
Assert.False(inserted);
|
||||
}
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Notifications.FindAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Original", loaded!.Subject);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task InsertIfNotExistsAsync_ConcurrentInserts_SameId_OnlyOneRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// CD-015 race coverage. The IF NOT EXISTS … INSERT pattern has a
|
||||
// check-then-act window: two concurrent sessions can both pass the
|
||||
// EXISTS check and both attempt the INSERT — the loser surfaces as a
|
||||
// SqlException with Number 2601/2627. The site→central handoff is
|
||||
// documented at-least-once with insert-if-not-exists, so this collision
|
||||
// IS the expected contention mode. The race losers MUST be swallowed
|
||||
// (not bubbled) so the site doesn't retry the same NotificationId
|
||||
// forever. Final row count must be exactly 1; no exceptions thrown.
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
Enumerable.Range(0, 50),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 50 },
|
||||
async (_, ct) =>
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var repo = new NotificationOutboxRepository(context);
|
||||
await repo.InsertIfNotExistsAsync(MakeNotification(id), ct);
|
||||
});
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var count = await readContext.Notifications
|
||||
.Where(n => n.NotificationId == id)
|
||||
.CountAsync();
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.Options;
|
||||
return new ScadaBridgeDbContext(options);
|
||||
}
|
||||
|
||||
private static Notification MakeNotification(
|
||||
string id,
|
||||
NotificationStatus status = NotificationStatus.Pending,
|
||||
string subject = "Subject")
|
||||
{
|
||||
return new Notification(
|
||||
id,
|
||||
NotificationType.Email,
|
||||
"Ops List",
|
||||
subject,
|
||||
"Body",
|
||||
"site-cd015")
|
||||
{
|
||||
Status = status,
|
||||
CreatedAt = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero),
|
||||
SiteEnqueuedAt = new DateTimeOffset(2026, 5, 20, 9, 59, 0, TimeSpan.Zero),
|
||||
};
|
||||
}
|
||||
}
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B3 (#22, #23 M3) integration tests for <see cref="SiteCallAuditRepository"/>.
|
||||
/// Uses the same <see cref="MsSqlMigrationFixture"/> as the Bundle B2 migration tests so
|
||||
/// the monotonic-upsert SQL executes against the real <c>SiteCalls</c> schema. Each test
|
||||
/// scopes its data by minting a fresh <see cref="TrackedOperationId"/> (or a per-test
|
||||
/// <c>SourceSite</c> suffix) so tests neither collide nor require teardown.
|
||||
/// </summary>
|
||||
public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_FreshId_InsertsOneRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var row = NewRow(id, status: "Submitted", retryCount: 0);
|
||||
await repo.UpsertAsync(row);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.TrackedOperationId == id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(loaded);
|
||||
Assert.Equal("Submitted", loaded[0].Status);
|
||||
Assert.Equal(0, loaded[0].RetryCount);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_AdvancedStatus_UpdatesRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Submitted (rank 0) → Forwarded (rank 1) → Attempted (rank 2) — every
|
||||
// step strictly advances the rank, so each upsert must mutate the row.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
|
||||
await repo.UpsertAsync(NewRow(id, status: "Forwarded", retryCount: 0));
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "transient 503"));
|
||||
|
||||
var loaded = await repo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Attempted", loaded!.Status);
|
||||
Assert.Equal(1, loaded.RetryCount);
|
||||
Assert.Equal("transient 503", loaded.LastError);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// First land Attempted (rank 2). A late-arriving Submitted (rank 0) must
|
||||
// NOT roll the row back — silent no-op.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "transient"));
|
||||
var attemptedSnapshot = await repo.GetAsync(id);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0, lastError: null));
|
||||
var afterStale = await repo.GetAsync(id);
|
||||
|
||||
Assert.NotNull(afterStale);
|
||||
Assert.Equal("Attempted", afterStale!.Status);
|
||||
Assert.Equal(5, afterStale.RetryCount);
|
||||
Assert.Equal("transient", afterStale.LastError);
|
||||
// UpdatedAtUtc should not have moved when the stale write was rejected.
|
||||
Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_SameStatus_IsNoOp()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "first"));
|
||||
var snapshot = await repo.GetAsync(id);
|
||||
|
||||
// Same rank (2) — repository must treat this as a no-op (no fields move).
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second"));
|
||||
var afterDuplicate = await repo.GetAsync(id);
|
||||
|
||||
Assert.NotNull(afterDuplicate);
|
||||
Assert.Equal("Attempted", afterDuplicate!.Status);
|
||||
Assert.Equal(1, afterDuplicate.RetryCount);
|
||||
Assert.Equal("first", afterDuplicate.LastError);
|
||||
Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_TerminalOverTerminal_IsNoOp()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Bundle B3 plan: terminal statuses share rank 3 and are mutually
|
||||
// exclusive — Delivered cannot overwrite Parked.
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 3, lastError: "parked-reason", terminal: true));
|
||||
var afterPark = await repo.GetAsync(id);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 4, lastError: null, terminal: true));
|
||||
var afterDeliveredAttempt = await repo.GetAsync(id);
|
||||
|
||||
Assert.NotNull(afterDeliveredAttempt);
|
||||
Assert.Equal("Parked", afterDeliveredAttempt!.Status);
|
||||
Assert.Equal("parked-reason", afterDeliveredAttempt.LastError);
|
||||
Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// 50 parallel inserters with the same id. The IF NOT EXISTS … INSERT
|
||||
// pattern has a check-then-act race; concurrent losers must surface as
|
||||
// silent duplicate-key swallows, not thrown exceptions. Final row
|
||||
// count must be exactly 1.
|
||||
var id = TrackedOperationId.New();
|
||||
var row = NewRow(id, status: "Submitted", retryCount: 0);
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
Enumerable.Range(0, 50),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 50 },
|
||||
async (_, ct) =>
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(row, ct);
|
||||
});
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var count = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.TrackedOperationId == id)
|
||||
.CountAsync();
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetAsync_KnownId_ReturnsRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
|
||||
|
||||
var loaded = await repo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(id, loaded!.TrackedOperationId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetAsync_UnknownId_ReturnsNull()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var loaded = await repo.GetAsync(TrackedOperationId.New());
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteA = NewSiteId();
|
||||
var siteB = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0.AddMinutes(1)));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteB, createdAtUtc: t0.AddMinutes(2)));
|
||||
|
||||
var rows = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: siteA),
|
||||
new SiteCallPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_KeysetPaging_NoOverlap()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Five rows with distinct CreatedAtUtc. Page-size 2 → page 1 returns
|
||||
// minutes 4,3; cursor (minutes 3) → page 2 returns minutes 2,1; cursor
|
||||
// (minutes 1) → page 3 returns minute 0.
|
||||
var t0 = new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: site, createdAtUtc: t0.AddMinutes(i)));
|
||||
}
|
||||
|
||||
var page1 = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: site),
|
||||
new SiteCallPaging(PageSize: 2));
|
||||
Assert.Equal(2, page1.Count);
|
||||
Assert.Equal(t0.AddMinutes(4), page1[0].CreatedAtUtc);
|
||||
Assert.Equal(t0.AddMinutes(3), page1[1].CreatedAtUtc);
|
||||
|
||||
var cursor1 = page1[^1];
|
||||
var page2 = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: site),
|
||||
new SiteCallPaging(
|
||||
PageSize: 2,
|
||||
AfterCreatedAtUtc: cursor1.CreatedAtUtc,
|
||||
AfterId: cursor1.TrackedOperationId));
|
||||
Assert.Equal(2, page2.Count);
|
||||
Assert.Equal(t0.AddMinutes(2), page2[0].CreatedAtUtc);
|
||||
Assert.Equal(t0.AddMinutes(1), page2[1].CreatedAtUtc);
|
||||
|
||||
var cursor2 = page2[^1];
|
||||
var page3 = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: site),
|
||||
new SiteCallPaging(
|
||||
PageSize: 2,
|
||||
AfterCreatedAtUtc: cursor2.CreatedAtUtc,
|
||||
AfterId: cursor2.TrackedOperationId));
|
||||
Assert.Single(page3);
|
||||
Assert.Equal(t0.AddMinutes(0), page3[0].CreatedAtUtc);
|
||||
|
||||
// No overlap across pages.
|
||||
var allIds = page1.Concat(page2).Concat(page3).Select(r => r.TrackedOperationId).ToHashSet();
|
||||
Assert.Equal(5, allIds.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Three stuck rows (non-terminal, created before the cutoff) interleaved
|
||||
// by CreatedAtUtc with non-stuck rows: recent non-terminal rows and an
|
||||
// old-but-terminal row. The stuck predicate is pushed into the SQL WHERE
|
||||
// alongside the keyset cursor, so each page must come back full of stuck
|
||||
// rows — never under-filled by a post-filter.
|
||||
var t0 = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
|
||||
var cutoff = t0.AddMinutes(10);
|
||||
|
||||
var stuckIds = new List<TrackedOperationId>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var stuckId = TrackedOperationId.New();
|
||||
stuckIds.Add(stuckId);
|
||||
// Stuck: non-terminal, created before the cutoff.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
stuckId, sourceSite: site, status: "Attempted",
|
||||
createdAtUtc: t0.AddMinutes(i)));
|
||||
// Not stuck: non-terminal but created after the cutoff.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), sourceSite: site, status: "Attempted",
|
||||
createdAtUtc: cutoff.AddMinutes(i + 1)));
|
||||
// Not stuck: created before the cutoff but terminal.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), sourceSite: site, status: "Delivered",
|
||||
createdAtUtc: t0.AddMinutes(i), terminal: true,
|
||||
terminalAtUtc: t0.AddMinutes(i + 1)));
|
||||
}
|
||||
|
||||
var filter = new SiteCallQueryFilter(SourceSite: site, StuckCutoffUtc: cutoff);
|
||||
|
||||
var page1 = await repo.QueryAsync(filter, new SiteCallPaging(PageSize: 2));
|
||||
Assert.Equal(2, page1.Count);
|
||||
Assert.All(page1, r => Assert.Null(r.TerminalAtUtc));
|
||||
Assert.All(page1, r => Assert.True(r.CreatedAtUtc < cutoff));
|
||||
|
||||
var cursor1 = page1[^1];
|
||||
var page2 = await repo.QueryAsync(
|
||||
filter,
|
||||
new SiteCallPaging(
|
||||
PageSize: 2,
|
||||
AfterCreatedAtUtc: cursor1.CreatedAtUtc,
|
||||
AfterId: cursor1.TrackedOperationId));
|
||||
// Only the third stuck row remains — no empty trailing page.
|
||||
Assert.Single(page2);
|
||||
Assert.Null(page2[0].TerminalAtUtc);
|
||||
Assert.True(page2[0].CreatedAtUtc < cutoff);
|
||||
|
||||
// Exactly the three stuck rows, no overlap, no non-stuck leakage.
|
||||
var returned = page1.Concat(page2).Select(r => r.TrackedOperationId).ToHashSet();
|
||||
Assert.Equal(stuckIds.ToHashSet(), returned);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// One row that's been Delivered for a long time (5 days ago) — should be purged.
|
||||
var oldId = TrackedOperationId.New();
|
||||
var fiveDaysAgo = DateTime.UtcNow.AddDays(-5);
|
||||
await repo.UpsertAsync(NewRow(
|
||||
oldId,
|
||||
sourceSite: site,
|
||||
status: "Delivered",
|
||||
retryCount: 1,
|
||||
createdAtUtc: fiveDaysAgo.AddMinutes(-1),
|
||||
updatedAtUtc: fiveDaysAgo,
|
||||
terminal: true,
|
||||
terminalAtUtc: fiveDaysAgo));
|
||||
|
||||
var purged = await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
Assert.True(purged >= 1, $"Expected at least one purged row; got {purged}.");
|
||||
Assert.Null(await repo.GetAsync(oldId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Non-terminal row: never eligible.
|
||||
var activeId = TrackedOperationId.New();
|
||||
await repo.UpsertAsync(NewRow(
|
||||
activeId,
|
||||
sourceSite: site,
|
||||
status: "Attempted",
|
||||
retryCount: 1,
|
||||
createdAtUtc: DateTime.UtcNow.AddDays(-10),
|
||||
updatedAtUtc: DateTime.UtcNow.AddDays(-10),
|
||||
terminal: false));
|
||||
|
||||
// Recent terminal row: TerminalAtUtc within the keep window.
|
||||
var recentTerminalId = TrackedOperationId.New();
|
||||
await repo.UpsertAsync(NewRow(
|
||||
recentTerminalId,
|
||||
sourceSite: site,
|
||||
status: "Delivered",
|
||||
retryCount: 0,
|
||||
createdAtUtc: DateTime.UtcNow.AddHours(-2),
|
||||
updatedAtUtc: DateTime.UtcNow.AddHours(-1),
|
||||
terminal: true,
|
||||
terminalAtUtc: DateTime.UtcNow.AddHours(-1)));
|
||||
|
||||
// Purge older than 1 day — both rows must survive.
|
||||
await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
Assert.NotNull(await repo.GetAsync(activeId));
|
||||
Assert.NotNull(await repo.GetAsync(recentTerminalId));
|
||||
}
|
||||
|
||||
// --- KPI snapshot tests -------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var stuckCutoff = now.AddMinutes(-10);
|
||||
var intervalSince = now.AddHours(-1);
|
||||
|
||||
// Buffered + stuck (non-terminal Attempted, created 30 min ago).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||
// Buffered but NOT stuck (non-terminal Attempted, created 2 min ago).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||
// Parked (terminal).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-4)));
|
||||
// Delivered within the interval.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-1)));
|
||||
// Failed within the interval.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Failed",
|
||||
createdAtUtc: now.AddMinutes(-6), updatedAtUtc: now.AddMinutes(-2),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-2)));
|
||||
// Delivered OUTSIDE the interval (2 hours ago) — must not count.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Delivered",
|
||||
createdAtUtc: now.AddHours(-3), updatedAtUtc: now.AddHours(-2),
|
||||
terminal: true, terminalAtUtc: now.AddHours(-2)));
|
||||
|
||||
var snapshot = await repo.ComputeKpisAsync(stuckCutoff, intervalSince);
|
||||
|
||||
// Counts are global; assert the floor since the table is shared with
|
||||
// other tests. The OUTSIDE-interval Delivered row proves the window
|
||||
// bounds the throughput counts.
|
||||
Assert.True(snapshot.BufferedCount >= 2);
|
||||
Assert.True(snapshot.ParkedCount >= 1);
|
||||
Assert.True(snapshot.StuckCount >= 1);
|
||||
Assert.True(snapshot.DeliveredLastInterval >= 1);
|
||||
Assert.True(snapshot.FailedLastInterval >= 1);
|
||||
Assert.NotNull(snapshot.OldestPendingAge);
|
||||
Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteA = NewSiteId();
|
||||
var siteB = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var stuckCutoff = now.AddMinutes(-10);
|
||||
var intervalSince = now.AddHours(-1);
|
||||
|
||||
// siteA: 2 buffered (one stuck), 1 parked.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteA, status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-4)));
|
||||
// siteB: 1 delivered within interval only.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteB, status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-1)));
|
||||
|
||||
var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, intervalSince);
|
||||
|
||||
var a = Assert.Single(perSite, s => s.SourceSite == siteA);
|
||||
Assert.Equal(2, a.BufferedCount);
|
||||
Assert.Equal(1, a.ParkedCount);
|
||||
Assert.Equal(1, a.StuckCount);
|
||||
Assert.NotNull(a.OldestPendingAge);
|
||||
|
||||
var b = Assert.Single(perSite, s => s.SourceSite == siteB);
|
||||
Assert.Equal(0, b.BufferedCount);
|
||||
Assert.Equal(1, b.DeliveredLastInterval);
|
||||
// siteB has no non-terminal rows — no oldest-pending age.
|
||||
Assert.Null(b.OldestPendingAge);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.Options;
|
||||
return new ScadaBridgeDbContext(options);
|
||||
}
|
||||
|
||||
private static string NewSiteId() =>
|
||||
"site-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
private static SiteCall NewRow(
|
||||
TrackedOperationId id,
|
||||
string? sourceSite = null,
|
||||
string status = "Submitted",
|
||||
int retryCount = 0,
|
||||
string? lastError = null,
|
||||
int? httpStatus = null,
|
||||
DateTime? createdAtUtc = null,
|
||||
DateTime? updatedAtUtc = null,
|
||||
bool terminal = false,
|
||||
DateTime? terminalAtUtc = null,
|
||||
string? sourceNode = null)
|
||||
{
|
||||
var created = createdAtUtc ?? DateTime.UtcNow;
|
||||
var updated = updatedAtUtc ?? created;
|
||||
DateTime? terminalAt = terminal
|
||||
? (terminalAtUtc ?? updated)
|
||||
: null;
|
||||
|
||||
return new SiteCall
|
||||
{
|
||||
TrackedOperationId = id,
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = sourceSite ?? NewSiteId(),
|
||||
SourceNode = sourceNode,
|
||||
Status = status,
|
||||
RetryCount = retryCount,
|
||||
LastError = lastError,
|
||||
HttpStatus = httpStatus,
|
||||
CreatedAtUtc = created,
|
||||
UpdatedAtUtc = updated,
|
||||
TerminalAtUtc = terminalAt,
|
||||
IngestedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
// --- SourceNode-stamping (Task 14) --------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): a fresh INSERT must persist the
|
||||
// SourceNode column verbatim — the central row carries the originating
|
||||
// site node name end-to-end.
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
|
||||
|
||||
var loaded = await repo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("node-a", loaded!.SourceNode);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): the UPDATE uses
|
||||
// COALESCE(@SourceNode, SourceNode) so a subsequent packet that does
|
||||
// NOT carry a SourceNode (legacy / reconciliation pull from an
|
||||
// unstamped node) MUST NOT blank out the value the first packet set.
|
||||
// Combined with the monotonic-rank guard the Status advances but the
|
||||
// SourceNode survives.
|
||||
//
|
||||
// Each step uses a fresh DbContext — raw-SQL UPDATEs bypass the
|
||||
// change tracker, so reusing a single context whose entity is already
|
||||
// tracked masks the post-UPDATE state on a follow-up FindAsync.
|
||||
var id = TrackedOperationId.New();
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
// First packet: stamped Submit from node-a.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
|
||||
}
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
// Later packet: rank-advancing Attempted with null SourceNode.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: null));
|
||||
}
|
||||
|
||||
await using (var readContext = CreateContext())
|
||||
{
|
||||
var readRepo = new SiteCallAuditRepository(readContext);
|
||||
var loaded = await readRepo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
// SourceNode preserved despite the null on the later packet.
|
||||
Assert.Equal("node-a", loaded!.SourceNode);
|
||||
// Status advanced — proves the UPDATE branch actually ran.
|
||||
Assert.Equal("Attempted", loaded.Status);
|
||||
Assert.Equal(1, loaded.RetryCount);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): per the COALESCE(@SourceNode,
|
||||
// SourceNode) semantics the column protects against a *null*
|
||||
// incoming value blanking a previously-stamped one, but a non-null
|
||||
// incoming value DOES replace the existing value on a rank-advancing
|
||||
// packet. This is the "last-non-null-wins on advance" behaviour the
|
||||
// SQL operator literally implements — see the comment in
|
||||
// SiteCallAuditRepository.UpsertAsync.
|
||||
//
|
||||
// In practice both stamps within a single lifecycle SHOULD carry the
|
||||
// same value (same node, same execution); a divergence would imply a
|
||||
// mid-lifecycle node change (e.g. failover handing off to node-b) and
|
||||
// letting the latest stamp through is arguably the right call. This
|
||||
// test pins the actual behaviour so we notice if the SQL gets
|
||||
// inverted (to a true first-write-wins COALESCE(SourceNode,
|
||||
// @SourceNode)) inadvertently.
|
||||
var id = TrackedOperationId.New();
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
|
||||
}
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-b"));
|
||||
}
|
||||
|
||||
await using (var readContext = CreateContext())
|
||||
{
|
||||
var readRepo = new SiteCallAuditRepository(readContext);
|
||||
var loaded = await readRepo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
// Incoming non-null wins — node-b replaces node-a on rank advance.
|
||||
Assert.Equal("node-b", loaded!.SourceNode);
|
||||
// Other monotonic fields advanced too — proves the UPDATE ran.
|
||||
Assert.Equal("Attempted", loaded.Status);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_FillsSourceNode_WhenInsertWasNullAndLaterPacketCarriesValue()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): when the column was left NULL by an
|
||||
// earlier unstamped packet, a later rank-advancing packet with a
|
||||
// non-null SourceNode fills it — the COALESCE(@SourceNode, SourceNode)
|
||||
// SQL operator returns @SourceNode when @SourceNode is non-null, so
|
||||
// the incoming value wins over the existing NULL. This is the
|
||||
// recovery path for an initially-unstamped lifecycle whose later
|
||||
// packets carry the node identity.
|
||||
//
|
||||
// The intermediate verification and final read use FRESH contexts —
|
||||
// FindAsync hits the change tracker first, so a cached entity from
|
||||
// an earlier read in the same context can mask a raw-SQL UPDATE.
|
||||
var id = TrackedOperationId.New();
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: null));
|
||||
}
|
||||
|
||||
// Verify the INSERT left SourceNode NULL via a fresh context.
|
||||
await using (var verifyContext = CreateContext())
|
||||
{
|
||||
var verifyRepo = new SiteCallAuditRepository(verifyContext);
|
||||
var afterInsert = await verifyRepo.GetAsync(id);
|
||||
Assert.NotNull(afterInsert);
|
||||
Assert.Null(afterInsert!.SourceNode);
|
||||
}
|
||||
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-a"));
|
||||
}
|
||||
|
||||
await using (var readContext = CreateContext())
|
||||
{
|
||||
var readRepo = new SiteCallAuditRepository(readContext);
|
||||
var loaded = await readRepo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("node-a", loaded!.SourceNode);
|
||||
Assert.Equal("Attempted", loaded.Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
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.Commons.Types.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
// Regression coverage for ConfigurationDatabase-010 (repositories / InstanceLocator lacked
|
||||
// direct tests) and ConfigurationDatabase-011 (inconsistent constructor null-guarding).
|
||||
|
||||
public class ExternalSystemRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly ExternalSystemRepository _repository;
|
||||
|
||||
public ExternalSystemRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new ExternalSystemRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddExternalSystem_AndGetById_RoundTrips()
|
||||
{
|
||||
var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
|
||||
await _repository.AddExternalSystemAsync(def);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetExternalSystemByIdAsync(def.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Sys", loaded!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMethodsByExternalSystemId_FiltersByParent()
|
||||
{
|
||||
var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
|
||||
await _repository.AddExternalSystemAsync(def);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.AddExternalSystemMethodAsync(
|
||||
new ExternalSystemMethod("M1", "GET", "/m1") { ExternalSystemDefinitionId = def.Id });
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var methods = await _repository.GetMethodsByExternalSystemIdAsync(def.Id);
|
||||
Assert.Single(methods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDatabaseConnection_RemovesEntity()
|
||||
{
|
||||
var conn = new DatabaseConnectionDefinition("Db", "Server=x;Database=y;");
|
||||
await _repository.AddDatabaseConnectionAsync(conn);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteDatabaseConnectionAsync(conn.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetDatabaseConnectionByIdAsync(conn.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!));
|
||||
}
|
||||
|
||||
// ── ExternalSystemGateway-011: name-keyed repository lookups ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetExternalSystemByName_ReturnsMatchingRow()
|
||||
{
|
||||
await _repository.AddExternalSystemAsync(
|
||||
new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey"));
|
||||
await _repository.AddExternalSystemAsync(
|
||||
new ExternalSystemDefinition("Beta", "https://beta.test", "Basic"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetExternalSystemByNameAsync("Beta");
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Beta", loaded!.Name);
|
||||
Assert.Equal("https://beta.test", loaded.EndpointUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExternalSystemByName_MissingName_ReturnsNull()
|
||||
{
|
||||
await _repository.AddExternalSystemAsync(
|
||||
new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetExternalSystemByNameAsync("DoesNotExist"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMethodByName_ReturnsMethodScopedToParentSystem()
|
||||
{
|
||||
var sysA = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey");
|
||||
var sysB = new ExternalSystemDefinition("SysB", "https://b.test", "ApiKey");
|
||||
await _repository.AddExternalSystemAsync(sysA);
|
||||
await _repository.AddExternalSystemAsync(sysB);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
// Same method name on two different systems — the lookup must be scoped.
|
||||
await _repository.AddExternalSystemMethodAsync(
|
||||
new ExternalSystemMethod("getData", "GET", "/a") { ExternalSystemDefinitionId = sysA.Id });
|
||||
await _repository.AddExternalSystemMethodAsync(
|
||||
new ExternalSystemMethod("getData", "POST", "/b") { ExternalSystemDefinitionId = sysB.Id });
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = await _repository.GetMethodByNameAsync(sysB.Id, "getData");
|
||||
|
||||
Assert.NotNull(method);
|
||||
Assert.Equal(sysB.Id, method!.ExternalSystemDefinitionId);
|
||||
Assert.Equal("POST", method.HttpMethod);
|
||||
Assert.Equal("/b", method.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMethodByName_MissingMethod_ReturnsNull()
|
||||
{
|
||||
var sys = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey");
|
||||
await _repository.AddExternalSystemAsync(sys);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetMethodByNameAsync(sys.Id, "noSuchMethod"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDatabaseConnectionByName_ReturnsMatchingRow()
|
||||
{
|
||||
await _repository.AddDatabaseConnectionAsync(
|
||||
new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;"));
|
||||
await _repository.AddDatabaseConnectionAsync(
|
||||
new DatabaseConnectionDefinition("Historian", "Server=hist;Database=h;"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetDatabaseConnectionByNameAsync("Historian");
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Historian", loaded!.Name);
|
||||
Assert.Equal("Server=hist;Database=h;", loaded.ConnectionString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDatabaseConnectionByName_MissingName_ReturnsNull()
|
||||
{
|
||||
await _repository.AddDatabaseConnectionAsync(
|
||||
new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetDatabaseConnectionByNameAsync("DoesNotExist"));
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly NotificationRepository _repository;
|
||||
|
||||
public NotificationRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new NotificationRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddNotificationList_WithRecipients_RoundTrips()
|
||||
{
|
||||
var list = new NotificationList("Ops");
|
||||
list.Recipients.Add(new NotificationRecipient("Ops Team", "ops@example.test"));
|
||||
await _repository.AddNotificationListAsync(list);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetListByNameAsync("Ops");
|
||||
Assert.NotNull(loaded);
|
||||
|
||||
var all = await _repository.GetAllNotificationListsAsync();
|
||||
Assert.Single(all);
|
||||
Assert.Single(all[0].Recipients);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSmtpConfiguration_AndGetById_RoundTrips()
|
||||
{
|
||||
var smtp = new SmtpConfiguration("smtp.example.test", "Basic", "from@example.test");
|
||||
await _repository.AddSmtpConfigurationAsync(smtp);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetSmtpConfigurationByIdAsync(smtp.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("smtp.example.test", loaded!.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteNotificationList_RemovesEntity()
|
||||
{
|
||||
var list = new NotificationList("ToDelete");
|
||||
await _repository.AddNotificationListAsync(list);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteNotificationListAsync(list.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetNotificationListByIdAsync(list.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationList_PersistsType()
|
||||
{
|
||||
var list = new NotificationList("ops") { Type = NotificationType.Email };
|
||||
await _repository.AddNotificationListAsync(list);
|
||||
await _repository.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = await _repository.GetListByNameAsync("ops");
|
||||
Assert.Equal(NotificationType.Email, loaded!.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new NotificationRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationOutboxConfigurationTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public NotificationOutboxConfigurationTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notification_FullyPopulated_RoundTrips()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
var siteEnqueuedAt = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);
|
||||
var createdAt = new DateTimeOffset(2026, 5, 19, 8, 0, 5, TimeSpan.Zero);
|
||||
var lastAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 1, 0, TimeSpan.Zero);
|
||||
var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero);
|
||||
var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero);
|
||||
var originExecutionId = Guid.NewGuid();
|
||||
var originParentExecutionId = Guid.NewGuid();
|
||||
|
||||
var notification = new Notification(id, NotificationType.Email, "Ops List",
|
||||
"High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north")
|
||||
{
|
||||
TypeData = "{\"channel\":\"email\"}",
|
||||
Status = NotificationStatus.Retrying,
|
||||
RetryCount = 3,
|
||||
LastError = "SMTP timeout",
|
||||
ResolvedTargets = "ops@example.test;duty@example.test",
|
||||
SourceInstanceId = "instance-42",
|
||||
SourceScript = "TankLevelAlarm",
|
||||
OriginExecutionId = originExecutionId,
|
||||
OriginParentExecutionId = originParentExecutionId,
|
||||
SiteEnqueuedAt = siteEnqueuedAt,
|
||||
CreatedAt = createdAt,
|
||||
LastAttemptAt = lastAttemptAt,
|
||||
NextAttemptAt = nextAttemptAt,
|
||||
DeliveredAt = deliveredAt,
|
||||
};
|
||||
|
||||
_context.Notifications.Add(notification);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.Notifications.FindAsync(id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(id, loaded!.NotificationId);
|
||||
Assert.Equal(NotificationType.Email, loaded.Type);
|
||||
Assert.Equal(NotificationStatus.Retrying, loaded.Status);
|
||||
Assert.Equal("Ops List", loaded.ListName);
|
||||
Assert.Equal("High Tank Level", loaded.Subject);
|
||||
Assert.Equal("Tank 4 exceeded the high level threshold.", loaded.Body);
|
||||
Assert.Equal("{\"channel\":\"email\"}", loaded.TypeData);
|
||||
Assert.Equal(3, loaded.RetryCount);
|
||||
Assert.Equal("SMTP timeout", loaded.LastError);
|
||||
Assert.Equal("ops@example.test;duty@example.test", loaded.ResolvedTargets);
|
||||
Assert.Equal("site-north", loaded.SourceSiteId);
|
||||
Assert.Equal("instance-42", loaded.SourceInstanceId);
|
||||
Assert.Equal("TankLevelAlarm", loaded.SourceScript);
|
||||
Assert.Equal(siteEnqueuedAt, loaded.SiteEnqueuedAt);
|
||||
Assert.Equal(createdAt, loaded.CreatedAt);
|
||||
Assert.Equal(lastAttemptAt, loaded.LastAttemptAt);
|
||||
Assert.Equal(nextAttemptAt, loaded.NextAttemptAt);
|
||||
Assert.Equal(deliveredAt, loaded.DeliveredAt);
|
||||
Assert.Equal(originExecutionId, loaded.OriginExecutionId);
|
||||
Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notification_NullOriginExecutionId_RoundTripsAsNull()
|
||||
{
|
||||
// Audit Log #23: OriginExecutionId is an additive nullable column —
|
||||
// notifications raised outside a script execution (or submitted before
|
||||
// the column existed) persist and reload it as null.
|
||||
var id = Guid.NewGuid().ToString();
|
||||
var notification = new Notification(id, NotificationType.Email, "Ops List",
|
||||
"Subject", "Body", "site-north");
|
||||
|
||||
_context.Notifications.Add(notification);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.Notifications.FindAsync(id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Null(loaded!.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notification_NullOriginParentExecutionId_RoundTripsAsNull()
|
||||
{
|
||||
// Audit Log ParentExecutionId: OriginParentExecutionId is an additive
|
||||
// nullable column — notifications from non-routed runs (or submitted
|
||||
// before the column existed) persist and reload it as null.
|
||||
var id = Guid.NewGuid().ToString();
|
||||
var notification = new Notification(id, NotificationType.Email, "Ops List",
|
||||
"Subject", "Body", "site-north");
|
||||
|
||||
_context.Notifications.Add(notification);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.Notifications.FindAsync(id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Null(loaded!.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notification_StatusPersistsAsString()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
var notification = new Notification(id, NotificationType.Email, "Ops List",
|
||||
"Subject", "Body", "site-north")
|
||||
{
|
||||
Status = NotificationStatus.Parked,
|
||||
};
|
||||
|
||||
_context.Notifications.Add(notification);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var statusText = await _context.Database
|
||||
.SqlQuery<string>($"SELECT Status AS Value FROM Notifications WHERE NotificationId = {id}")
|
||||
.SingleAsync();
|
||||
|
||||
Assert.Equal("Parked", statusText);
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage for the Notification Outbox repository (Task 5 of the notification-outbox feature).
|
||||
public class NotificationOutboxRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly NotificationOutboxRepository _repository;
|
||||
|
||||
public NotificationOutboxRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new NotificationOutboxRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
private static Notification MakeNotification(
|
||||
string id,
|
||||
NotificationStatus status = NotificationStatus.Pending,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? nextAttemptAt = null,
|
||||
DateTimeOffset? deliveredAt = null,
|
||||
string listName = "Ops List",
|
||||
string subject = "Subject",
|
||||
string sourceSiteId = "site-north",
|
||||
NotificationType type = NotificationType.Email)
|
||||
{
|
||||
return new Notification(id, type, listName, subject, "Body", sourceSiteId)
|
||||
{
|
||||
Status = status,
|
||||
CreatedAt = createdAt ?? new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero),
|
||||
NextAttemptAt = nextAttemptAt,
|
||||
DeliveredAt = deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
// InsertIfNotExistsAsync coverage lives in
|
||||
// tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs
|
||||
// — the method is raw-SQL (IF NOT EXISTS … INSERT) so it must execute against
|
||||
// SQL Server, not the SQLite in-memory provider this class uses.
|
||||
|
||||
[Fact]
|
||||
public async Task GetDueAsync_ReturnsPendingAndDueRetrying_OrderedByCreatedAt_CappedAtBatchSize()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 5, 19, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var pendingOld = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-30));
|
||||
var pendingNew = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-10));
|
||||
var retryingDue = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||
createdAt: now.AddMinutes(-20), nextAttemptAt: now.AddMinutes(-1));
|
||||
var retryingNotDue = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||
createdAt: now.AddMinutes(-25), nextAttemptAt: now.AddMinutes(5));
|
||||
var delivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||
createdAt: now.AddMinutes(-40));
|
||||
var parked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||
createdAt: now.AddMinutes(-45));
|
||||
|
||||
_context.Notifications.AddRange(pendingOld, pendingNew, retryingDue, retryingNotDue, delivered, parked);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var due = await _repository.GetDueAsync(now, batchSize: 10);
|
||||
|
||||
// pendingOld, retryingDue, pendingNew are due; ordered by CreatedAt ascending.
|
||||
Assert.Equal(
|
||||
new[] { pendingOld.NotificationId, retryingDue.NotificationId, pendingNew.NotificationId },
|
||||
due.Select(n => n.NotificationId).ToArray());
|
||||
|
||||
var capped = await _repository.GetDueAsync(now, batchSize: 2);
|
||||
Assert.Equal(2, capped.Count);
|
||||
Assert.Equal(pendingOld.NotificationId, capped[0].NotificationId);
|
||||
Assert.Equal(retryingDue.NotificationId, capped[1].NotificationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsRowOrNull()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
_context.Notifications.Add(MakeNotification(id));
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
Assert.NotNull(await _repository.GetByIdAsync(id));
|
||||
Assert.Null(await _repository.GetByIdAsync("does-not-exist"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsStatusTransition()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
_context.Notifications.Add(MakeNotification(id, NotificationStatus.Pending));
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _repository.GetByIdAsync(id);
|
||||
loaded!.Status = NotificationStatus.Delivered;
|
||||
loaded.DeliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero);
|
||||
await _repository.UpdateAsync(loaded);
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var reloaded = await _context.Notifications.FindAsync(id);
|
||||
Assert.Equal(NotificationStatus.Delivered, reloaded!.Status);
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero), reloaded.DeliveredAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_AppliesFilters_OrdersByCreatedAtDescending_AndPaginates()
|
||||
{
|
||||
var baseTime = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// 5 matching rows for site-north / Ops List, plus noise.
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
_context.Notifications.Add(MakeNotification(
|
||||
Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: baseTime.AddMinutes(i),
|
||||
subject: $"Tank Level {i}"));
|
||||
}
|
||||
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
|
||||
sourceSiteId: "site-south", subject: "Other Site"));
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var filter = new NotificationOutboxFilter(SourceSiteId: "site-north", ListName: "Ops List");
|
||||
var (rows, total) = await _repository.QueryAsync(filter, pageNumber: 1, pageSize: 3);
|
||||
|
||||
Assert.Equal(5, total);
|
||||
Assert.Equal(3, rows.Count);
|
||||
// Descending by CreatedAt: Tank Level 4, 3, 2.
|
||||
Assert.Equal("Tank Level 4", rows[0].Subject);
|
||||
Assert.Equal("Tank Level 3", rows[1].Subject);
|
||||
Assert.Equal("Tank Level 2", rows[2].Subject);
|
||||
|
||||
var (page2, _) = await _repository.QueryAsync(filter, pageNumber: 2, pageSize: 3);
|
||||
Assert.Equal(2, page2.Count);
|
||||
Assert.Equal("Tank Level 1", page2[0].Subject);
|
||||
Assert.Equal("Tank Level 0", page2[1].Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_SubjectKeyword_UsesContains()
|
||||
{
|
||||
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), subject: "Tank Level High"));
|
||||
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), subject: "Pump Failure"));
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var (rows, total) = await _repository.QueryAsync(
|
||||
new NotificationOutboxFilter(SubjectKeyword: "Level"), pageNumber: 1, pageSize: 10);
|
||||
|
||||
Assert.Equal(1, total);
|
||||
Assert.Equal("Tank Level High", rows[0].Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_StuckOnly_ReturnsNonTerminalRowsOlderThanCutoff()
|
||||
{
|
||||
var cutoff = new DateTimeOffset(2026, 5, 19, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var stuckPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: cutoff.AddHours(-2), subject: "Stuck Pending");
|
||||
var stuckRetrying = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||
createdAt: cutoff.AddHours(-3), subject: "Stuck Retrying");
|
||||
var freshPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: cutoff.AddHours(1), subject: "Fresh");
|
||||
var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||
createdAt: cutoff.AddHours(-5), subject: "Old Delivered");
|
||||
|
||||
_context.Notifications.AddRange(stuckPending, stuckRetrying, freshPending, oldDelivered);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var (rows, total) = await _repository.QueryAsync(
|
||||
new NotificationOutboxFilter(StuckOnly: true, StuckCutoff: cutoff),
|
||||
pageNumber: 1, pageSize: 10);
|
||||
|
||||
Assert.Equal(2, total);
|
||||
Assert.Equal(
|
||||
new[] { "Stuck Pending", "Stuck Retrying" },
|
||||
rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FromTo_FilterAgainstCreatedAt()
|
||||
{
|
||||
var baseTime = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
|
||||
createdAt: baseTime.AddHours(i), subject: $"Row {i}"));
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var (rows, total) = await _repository.QueryAsync(
|
||||
new NotificationOutboxFilter(From: baseTime.AddHours(1), To: baseTime.AddHours(3)),
|
||||
pageNumber: 1, pageSize: 10);
|
||||
|
||||
Assert.Equal(3, total);
|
||||
Assert.Equal(
|
||||
new[] { "Row 1", "Row 2", "Row 3" },
|
||||
rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_StatusAndTypeFilters()
|
||||
{
|
||||
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||
subject: "Parked Row"));
|
||||
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
subject: "Pending Row"));
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var (rows, total) = await _repository.QueryAsync(
|
||||
new NotificationOutboxFilter(Status: NotificationStatus.Parked, Type: NotificationType.Email),
|
||||
pageNumber: 1, pageSize: 10);
|
||||
|
||||
Assert.Equal(1, total);
|
||||
Assert.Equal("Parked Row", rows[0].Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteTerminalOlderThanAsync_DeletesTerminalRowsOlderThanCutoff_LeavesOthers()
|
||||
{
|
||||
var cutoff = new DateTimeOffset(2026, 5, 19, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||
createdAt: cutoff.AddHours(-1));
|
||||
var oldParked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||
createdAt: cutoff.AddHours(-2));
|
||||
var oldDiscarded = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Discarded,
|
||||
createdAt: cutoff.AddHours(-3));
|
||||
var recentDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||
createdAt: cutoff.AddHours(1));
|
||||
var oldPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: cutoff.AddHours(-4));
|
||||
|
||||
_context.Notifications.AddRange(oldDelivered, oldParked, oldDiscarded, recentDelivered, oldPending);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var deleted = await _repository.DeleteTerminalOlderThanAsync(cutoff);
|
||||
|
||||
Assert.Equal(3, deleted);
|
||||
var remaining = await _context.Notifications.Select(n => n.NotificationId).ToListAsync();
|
||||
Assert.Equal(2, remaining.Count);
|
||||
Assert.Contains(recentDelivered.NotificationId, remaining);
|
||||
Assert.Contains(oldPending.NotificationId, remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeKpisAsync_ComputesSnapshotFields()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var stuckCutoff = now.AddMinutes(-30);
|
||||
var deliveredSince = now.AddHours(-1);
|
||||
|
||||
var oldestPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: now.AddHours(-2)); // stuck
|
||||
var freshPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||
createdAt: now.AddMinutes(-5)); // not stuck
|
||||
var stuckRetrying = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||
createdAt: now.AddMinutes(-45)); // stuck
|
||||
var parked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||
createdAt: now.AddHours(-3));
|
||||
var recentlyDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||
createdAt: now.AddHours(-4), deliveredAt: now.AddMinutes(-10));
|
||||
var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||
createdAt: now.AddHours(-5), deliveredAt: now.AddHours(-2));
|
||||
|
||||
_context.Notifications.AddRange(oldestPending, freshPending, stuckRetrying, parked,
|
||||
recentlyDelivered, oldDelivered);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var kpis = await _repository.ComputeKpisAsync(stuckCutoff, deliveredSince);
|
||||
|
||||
Assert.Equal(3, kpis.QueueDepth); // 2 pending + 1 retrying
|
||||
Assert.Equal(2, kpis.StuckCount); // oldestPending + stuckRetrying
|
||||
Assert.Equal(1, kpis.ParkedCount); // parked
|
||||
Assert.Equal(1, kpis.DeliveredLastInterval); // recentlyDelivered only
|
||||
Assert.NotNull(kpis.OldestPendingAge);
|
||||
// Oldest non-terminal row is oldestPending (created ~2h ago).
|
||||
Assert.True(kpis.OldestPendingAge!.Value >= TimeSpan.FromMinutes(115));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeKpisAsync_NoNonTerminalRows_OldestPendingAgeIsNull()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
|
||||
NotificationStatus.Delivered, createdAt: now.AddHours(-1), deliveredAt: now.AddMinutes(-5)));
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var kpis = await _repository.ComputeKpisAsync(now.AddMinutes(-30), now.AddHours(-1));
|
||||
|
||||
Assert.Equal(0, kpis.QueueDepth);
|
||||
Assert.Equal(0, kpis.StuckCount);
|
||||
Assert.Null(kpis.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new NotificationOutboxRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class SiteRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly SiteRepository _repository;
|
||||
|
||||
public SiteRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new SiteRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSite_AndGetByIdentifier_RoundTrips()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
await _repository.AddSiteAsync(site);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetSiteByIdentifierAsync("S-001");
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Site1", loaded!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSite_ViaStubAttachPath_RemovesEntity()
|
||||
{
|
||||
// Exercises the stub-attach delete fallback: the entity is not tracked because the
|
||||
// ChangeTracker is cleared, forcing the Local-miss branch in DeleteSiteAsync.
|
||||
var site = new Site("Site1", "S-001");
|
||||
await _repository.AddSiteAsync(site);
|
||||
await _repository.SaveChangesAsync();
|
||||
var id = site.Id;
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
await _repository.DeleteSiteAsync(id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetSiteByIdAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDataConnection_ViaStubAttachPath_RemovesEntity()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
await _repository.AddSiteAsync(site);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
||||
await _repository.AddDataConnectionAsync(conn);
|
||||
await _repository.SaveChangesAsync();
|
||||
var id = conn.Id;
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
await _repository.DeleteDataConnectionAsync(id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetDataConnectionByIdAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesBySiteId_FiltersBySite()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instances = await _repository.GetInstancesBySiteIdAsync(site.Id);
|
||||
Assert.Single(instances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new SiteRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class DeploymentManagerRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly DeploymentManagerRepository _repository;
|
||||
|
||||
public DeploymentManagerRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new DeploymentManagerRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
private async Task<Instance> SeedInstanceAsync()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
return instance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddDeploymentRecord_AndGetCurrentStatus_ReturnsMostRecent()
|
||||
{
|
||||
var instance = await SeedInstanceAsync();
|
||||
|
||||
await _repository.AddDeploymentRecordAsync(
|
||||
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) });
|
||||
await _repository.AddDeploymentRecordAsync(
|
||||
new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id);
|
||||
Assert.NotNull(current);
|
||||
Assert.Equal("d-002", current!.DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity()
|
||||
{
|
||||
var instance = await SeedInstanceAsync();
|
||||
var record = new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow };
|
||||
await _repository.AddDeploymentRecordAsync(record);
|
||||
await _repository.SaveChangesAsync();
|
||||
var id = record.Id;
|
||||
var rowVersion = record.RowVersion ?? Array.Empty<byte>();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
await _repository.DeleteDeploymentRecordAsync(id, rowVersion);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-024: CD-017 added optimistic-concurrency to
|
||||
/// <c>DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion)</c> — the stub-attach
|
||||
/// path now seeds <c>OriginalValues["RowVersion"]</c> from the caller's last-observed
|
||||
/// value so the generated SQL becomes <c>DELETE … WHERE Id = @id AND RowVersion = @prior</c>.
|
||||
/// This test pins the production-shape happy path: caller holds the entity's CURRENT
|
||||
/// RowVersion, clears the change-tracker (i.e. no tracked instance — exactly the M&V
|
||||
/// admin / handler shape), calls Delete with that token, and the delete completes
|
||||
/// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds()
|
||||
{
|
||||
// STM: CD-024-RowVersionDeleteHappyPath marker.
|
||||
var instance = await SeedInstanceAsync();
|
||||
var record = new DeploymentRecord("d-rv-001", "admin")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
DeployedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
await _repository.AddDeploymentRecordAsync(record);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
// Capture the entity's CURRENT RowVersion (the one the caller would have
|
||||
// read from a prior GetDeploymentRecordByIdAsync), then detach so the
|
||||
// delete travels through the stub-attach branch (no tracked entity).
|
||||
var id = record.Id;
|
||||
var currentRowVersion = record.RowVersion ?? Array.Empty<byte>();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
// No NotSupported/Concurrency exception should fire on this code path.
|
||||
await _repository.DeleteDeploymentRecordAsync(id, currentRowVersion);
|
||||
var affected = await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Equal(1, affected);
|
||||
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
|
||||
{
|
||||
// DeploymentRecord has a Restrict FK to Instance; DeleteInstanceAsync must remove
|
||||
// the dependent deployment records explicitly or the delete would fail.
|
||||
var instance = await SeedInstanceAsync();
|
||||
await _repository.AddDeploymentRecordAsync(
|
||||
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteInstanceAsync(instance.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
Assert.Null(await _repository.GetInstanceByIdAsync(instance.Id));
|
||||
Assert.Empty(await _repository.GetDeploymentsByInstanceIdAsync(instance.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new DeploymentManagerRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceLocatorTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly InstanceLocator _locator;
|
||||
|
||||
public InstanceLocatorTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_locator = new InstanceLocator(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteIdForInstance_WhenFound_ReturnsSiteIdentifier()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.Add(new Instance("Pump1") { SiteId = site.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _locator.GetSiteIdForInstanceAsync("Pump1");
|
||||
Assert.Equal("SITE-001", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull()
|
||||
{
|
||||
var result = await _locator.GetSiteIdForInstanceAsync("DoesNotExist");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullContext_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new InstanceLocator(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
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;
|
||||
|
||||
public class SecurityRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly SecurityRepository _repository;
|
||||
|
||||
public SecurityRepositoryTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaBridgeDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
_repository = new SecurityRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMapping_AndGetById_ReturnsMapping()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("CN=Admins,DC=test", "Admin");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("CN=Admins,DC=test", loaded.LdapGroupName);
|
||||
Assert.Equal("Admin", loaded.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllMappings_ReturnsAll()
|
||||
{
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Admin"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Design"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
// +1 for seed data
|
||||
var all = await _repository.GetAllMappingsAsync();
|
||||
Assert.True(all.Count >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMappingsByRole_FiltersCorrectly()
|
||||
{
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Design"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployment"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var designMappings = await _repository.GetMappingsByRoleAsync("Design");
|
||||
// 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]
|
||||
public async Task UpdateMapping_PersistsChange()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("OldGroup", "Admin");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
mapping.Role = "Design";
|
||||
await _repository.UpdateMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.Equal("Design", loaded!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteMapping_RemovesEntity()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("ToDelete", "Admin");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteMappingAsync(mapping.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddScopeRule_AndGetForMapping()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Deployers", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rules = await _repository.GetScopeRulesForMappingAsync(mapping.Id);
|
||||
Assert.Single(rules);
|
||||
Assert.Equal(site.Id, rules[0].SiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScopeRuleById_ReturnsRule()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(mapping.Id, loaded.LdapGroupMappingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScopeRule_PersistsChange()
|
||||
{
|
||||
var site1 = new Site("Site1", "SITE-001");
|
||||
var site2 = new Site("Site2", "SITE-002");
|
||||
_context.Sites.AddRange(site1, site2);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
rule.SiteId = site2.Id;
|
||||
await _repository.UpdateScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id);
|
||||
Assert.Equal(site2.Id, loaded!.SiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteScopeRule_RemovesEntity()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteScopeRuleAsync(rule.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id);
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
}
|
||||
|
||||
public class CentralUiRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly CentralUiRepository _repository;
|
||||
|
||||
public CentralUiRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new CentralUiRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllSites_ReturnsOrderedByName()
|
||||
{
|
||||
_context.Sites.AddRange(
|
||||
new Site("Zulu", "Z-001"),
|
||||
new Site("Alpha", "A-001"));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var sites = await _repository.GetAllSitesAsync();
|
||||
Assert.Equal(2, sites.Count);
|
||||
Assert.Equal("Alpha", sites[0].Name);
|
||||
Assert.Equal("Zulu", sites[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesFiltered_BySiteId()
|
||||
{
|
||||
var site1 = new Site("Site1", "S-001");
|
||||
var site2 = new Site("Site2", "S-002");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.AddRange(site1, site2);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.AddRange(
|
||||
new Instance("Inst1") { SiteId = site1.Id, TemplateId = template.Id },
|
||||
new Instance("Inst2") { SiteId = site2.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instances = await _repository.GetInstancesFilteredAsync(siteId: site1.Id);
|
||||
Assert.Single(instances);
|
||||
Assert.Equal("Inst1", instances[0].UniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesFiltered_BySearchTerm()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.AddRange(
|
||||
new Instance("PumpStation1") { SiteId = site.Id, TemplateId = template.Id },
|
||||
new Instance("TankLevel1") { SiteId = site.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instances = await _repository.GetInstancesFilteredAsync(searchTerm: "Pump");
|
||||
Assert.Single(instances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentDeployments_ReturnsInReverseChronological()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.DeploymentRecords.AddRange(
|
||||
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-2) },
|
||||
new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) },
|
||||
new DeploymentRecord("d-003", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var recent = await _repository.GetRecentDeploymentsAsync(2);
|
||||
Assert.Equal(2, recent.Count);
|
||||
Assert.Equal("d-003", recent[0].DeploymentId);
|
||||
Assert.Equal("d-002", recent[1].DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByUser()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("user1", "Update", "Instance", "2", "I1") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(user: "admin");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(1, total);
|
||||
Assert.Equal("admin", entries[0].User);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByEntityType()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Create", "Instance", "2", "I1") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityType: "Template");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(1, total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByActionType()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Delete", "Template", "2", "T2") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(action: "Delete");
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByTimeRange()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = now.AddHours(-5) },
|
||||
new AuditLogEntry("admin", "Update", "Template", "2", "T2") { Timestamp = now.AddHours(-1) });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(from: now.AddHours(-2));
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByEntityId()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Create", "Template", "2", "T2") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityId: "1");
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByEntityName()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "PumpStation") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Create", "Template", "2", "TankLevel") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityName: "Pump");
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByBundleImportId()
|
||||
{
|
||||
// T24 — Bundle Import filter on the Configuration Audit Log page is
|
||||
// backed by the new optional bundleImportId arg on the repo query.
|
||||
// Only rows stamped with the given id should come back.
|
||||
var importA = Guid.NewGuid();
|
||||
var importB = Guid.NewGuid();
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1")
|
||||
{ Timestamp = DateTimeOffset.UtcNow, BundleImportId = importA },
|
||||
new AuditLogEntry("admin", "Create", "Template", "2", "T2")
|
||||
{ Timestamp = DateTimeOffset.UtcNow, BundleImportId = importB },
|
||||
new AuditLogEntry("admin", "Update", "Template", "3", "T3")
|
||||
{ Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(bundleImportId: importA);
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(1, total);
|
||||
Assert.Equal(importA, entries[0].BundleImportId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_ReverseChronologicalWithPagination()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
_context.AuditLogEntries.Add(new AuditLogEntry("admin", "Create", "Template", i.ToString(), $"T{i}")
|
||||
{
|
||||
Timestamp = now.AddMinutes(i)
|
||||
});
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (page1, total) = await _repository.GetAuditLogEntriesAsync(page: 1, pageSize: 3);
|
||||
Assert.Equal(10, total);
|
||||
Assert.Equal(3, page1.Count);
|
||||
Assert.Equal("T9", page1[0].EntityName); // Most recent first
|
||||
|
||||
var (page2, _) = await _repository.GetAuditLogEntriesAsync(page: 2, pageSize: 3);
|
||||
Assert.Equal(3, page2.Count);
|
||||
Assert.Equal("T6", page2[0].EntityName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateTree_IncludesChildren()
|
||||
{
|
||||
var template = new Template("TestTemplate");
|
||||
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Int32 });
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var tree = await _repository.GetTemplateTreeAsync();
|
||||
Assert.NotEmpty(tree);
|
||||
var loaded = tree.First(t => t.Name == "TestTemplate");
|
||||
Assert.Single(loaded.Attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAreaTree_ReturnsHierarchy()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
_context.Sites.Add(site);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var parent = new Area("Building A") { SiteId = site.Id };
|
||||
_context.Areas.Add(parent);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var child = new Area("Floor 1") { SiteId = site.Id, ParentAreaId = parent.Id };
|
||||
_context.Areas.Add(child);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var areas = await _repository.GetAreaTreeBySiteIdAsync(site.Id);
|
||||
Assert.Equal(2, areas.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class SchemaConfigurationTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public SchemaConfigurationTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-006: the gRPC node-address columns must be length-bounded
|
||||
// (HasMaxLength(500)) consistently with the sibling NodeAAddress/NodeBAddress columns,
|
||||
// rather than being left to map to nvarchar(max).
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(Site.GrpcNodeAAddress))]
|
||||
[InlineData(nameof(Site.GrpcNodeBAddress))]
|
||||
public void GrpcNodeAddressColumns_AreLengthBoundedTo500(string propertyName)
|
||||
{
|
||||
var property = _context.Model
|
||||
.FindEntityType(typeof(Site))!
|
||||
.FindProperty(propertyName)!;
|
||||
|
||||
Assert.Equal(500, property.GetMaxLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(Site.NodeAAddress))]
|
||||
[InlineData(nameof(Site.NodeBAddress))]
|
||||
public void GrpcNodeAddressColumns_MatchSiblingNodeAddressBounds(string siblingPropertyName)
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(Site))!;
|
||||
var siblingMaxLength = entity.FindProperty(siblingPropertyName)!.GetMaxLength();
|
||||
|
||||
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeAAddress))!.GetMaxLength());
|
||||
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeBAddress))!.GetMaxLength());
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-014: the encrypting value converter must be applied
|
||||
// uniformly to all three secret-bearing columns, including the non-nullable
|
||||
// DatabaseConnectionDefinition.ConnectionString. A regression here (e.g. the
|
||||
// converter dropped from one HasConversion call) would silently store a secret
|
||||
// in plaintext.
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(SmtpConfiguration), nameof(SmtpConfiguration.Credentials))]
|
||||
[InlineData(typeof(ExternalSystemDefinition), nameof(ExternalSystemDefinition.AuthConfiguration))]
|
||||
[InlineData(typeof(DatabaseConnectionDefinition), nameof(DatabaseConnectionDefinition.ConnectionString))]
|
||||
public void SecretColumns_AllHaveEncryptedStringConverterApplied(Type entityType, string propertyName)
|
||||
{
|
||||
var converter = _context.Model
|
||||
.FindEntityType(entityType)!
|
||||
.FindProperty(propertyName)!
|
||||
.GetValueConverter();
|
||||
|
||||
Assert.IsType<EncryptedStringConverter>(converter);
|
||||
}
|
||||
}
|
||||
|
||||
public class SplitQueryBehaviourTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly TemplateEngineRepository _repository;
|
||||
|
||||
public SplitQueryBehaviourTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new TemplateEngineRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-009: the multi-collection eager-load queries were switched to
|
||||
// AsSplitQuery() to avoid cartesian-product joins. The result set must be unchanged —
|
||||
// every member collection still fully populated, with no row duplication.
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication()
|
||||
{
|
||||
var template = new Template("MultiMember");
|
||||
for (int i = 0; i < 3; i++)
|
||||
template.Attributes.Add(new TemplateAttribute($"Attr{i}"));
|
||||
for (int i = 0; i < 2; i++)
|
||||
template.Alarms.Add(new TemplateAlarm($"Alarm{i}"));
|
||||
for (int i = 0; i < 4; i++)
|
||||
template.Scripts.Add(new TemplateScript($"Script{i}", "return 1;"));
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var all = await _repository.GetAllTemplatesAsync();
|
||||
|
||||
var loaded = Assert.Single(all);
|
||||
// A cartesian-product single query would yield 3 x 2 x 4 = 24 joined rows; the
|
||||
// collections must still contain exactly the inserted counts.
|
||||
Assert.Equal(3, loaded.Attributes.Count);
|
||||
Assert.Equal(2, loaded.Alarms.Count);
|
||||
Assert.Equal(4, loaded.Scripts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections()
|
||||
{
|
||||
var template = new Template("Single");
|
||||
template.Attributes.Add(new TemplateAttribute("A1"));
|
||||
template.Attributes.Add(new TemplateAttribute("A2"));
|
||||
template.Scripts.Add(new TemplateScript("S1", "return 1;"));
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _repository.GetTemplateByIdAsync(template.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(2, loaded!.Attributes.Count);
|
||||
Assert.Single(loaded.Scripts);
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-012: the ApiKey table must persist the bearer credential
|
||||
// as a hash column (KeyHash) and must NOT carry a plaintext KeyValue column.
|
||||
|
||||
[Fact]
|
||||
public void ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed()
|
||||
{
|
||||
var entityType = _context.Model.FindEntityType(typeof(ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey))!;
|
||||
|
||||
var keyHash = entityType.FindProperty("KeyHash");
|
||||
Assert.NotNull(keyHash);
|
||||
Assert.False(keyHash!.IsNullable);
|
||||
|
||||
var hashIndex = entityType.GetIndexes()
|
||||
.FirstOrDefault(i => i.Properties.Any(p => p.Name == "KeyHash"));
|
||||
Assert.NotNull(hashIndex);
|
||||
Assert.True(hashIndex!.IsUnique);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiKey_HasNoPlaintextKeyValueColumn()
|
||||
{
|
||||
var entityType = _context.Model.FindEntityType(typeof(ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey))!;
|
||||
|
||||
Assert.Null(entityType.FindProperty("KeyValue"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for ConfigurationDatabase-004: secret-bearing columns
|
||||
/// (SMTP credentials, external-system auth config, database connection strings)
|
||||
/// must be encrypted at rest, not persisted verbatim.
|
||||
/// </summary>
|
||||
public class SecretEncryptionTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly IDataProtectionProvider _protectionProvider;
|
||||
|
||||
public SecretEncryptionTests()
|
||||
{
|
||||
_protectionProvider = new EphemeralDataProtectionProvider();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
_context = new ScadaBridgeDbContext(options, _protectionProvider);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatabaseConnectionDefinition_ConnectionString_StoredEncrypted_RoundTrips()
|
||||
{
|
||||
const string secret = "Server=db;Database=X;User Id=svc;Password=SuperSecret123!";
|
||||
var def = new DatabaseConnectionDefinition("PrimaryDb", secret);
|
||||
_context.DatabaseConnectionDefinitions.Add(def);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
// Raw column value must not be the plaintext secret.
|
||||
var raw = await ReadRawColumnAsync("DatabaseConnectionDefinitions", "ConnectionString", def.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.NotEqual(secret, raw);
|
||||
Assert.DoesNotContain("SuperSecret123!", raw);
|
||||
|
||||
// Reading back through EF must transparently decrypt.
|
||||
var loaded = await _context.DatabaseConnectionDefinitions.SingleAsync(d => d.Id == def.Id);
|
||||
Assert.Equal(secret, loaded.ConnectionString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SmtpConfiguration_Credentials_StoredEncrypted_RoundTrips()
|
||||
{
|
||||
const string secret = "client_secret=oauth2-abc-very-secret";
|
||||
var smtp = new SmtpConfiguration("smtp.example.com", "OAuth2", "noreply@example.com")
|
||||
{
|
||||
Credentials = secret
|
||||
};
|
||||
_context.SmtpConfigurations.Add(smtp);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var raw = await ReadRawColumnAsync("SmtpConfigurations", "Credentials", smtp.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.NotEqual(secret, raw);
|
||||
Assert.DoesNotContain("oauth2-abc-very-secret", raw);
|
||||
|
||||
var loaded = await _context.SmtpConfigurations.SingleAsync(s => s.Id == smtp.Id);
|
||||
Assert.Equal(secret, loaded.Credentials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExternalSystemDefinition_AuthConfiguration_StoredEncrypted_RoundTrips()
|
||||
{
|
||||
const string secret = "{\"apiKey\":\"live-key-do-not-leak\"}";
|
||||
var ext = new ExternalSystemDefinition("Erp", "https://erp.example.com", "ApiKey")
|
||||
{
|
||||
AuthConfiguration = secret
|
||||
};
|
||||
_context.ExternalSystemDefinitions.Add(ext);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var raw = await ReadRawColumnAsync("ExternalSystemDefinitions", "AuthConfiguration", ext.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.NotEqual(secret, raw);
|
||||
Assert.DoesNotContain("live-key-do-not-leak", raw);
|
||||
|
||||
var loaded = await _context.ExternalSystemDefinitions.SingleAsync(e => e.Id == ext.Id);
|
||||
Assert.Equal(secret, loaded.AuthConfiguration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SmtpConfiguration_NullCredentials_RoundTripsAsNull()
|
||||
{
|
||||
var smtp = new SmtpConfiguration("smtp.example.com", "None", "noreply@example.com")
|
||||
{
|
||||
Credentials = null
|
||||
};
|
||||
_context.SmtpConfigurations.Add(smtp);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.SmtpConfigurations.SingleAsync(s => s.Id == smtp.Id);
|
||||
Assert.Null(loaded.Credentials);
|
||||
}
|
||||
|
||||
private async Task<string?> ReadRawColumnAsync(string table, string column, int id)
|
||||
{
|
||||
var connection = _context.Database.GetDbConnection();
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"SELECT \"{column}\" FROM \"{table}\" WHERE \"Id\" = $id";
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = "$id";
|
||||
p.Value = id;
|
||||
cmd.Parameters.Add(p);
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result == null || result == DBNull.Value ? null : (string)result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class SeedDataTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public SeedDataTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaBridgeDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedData_AdminMappingExists()
|
||||
{
|
||||
var adminMapping = await _context.LdapGroupMappings
|
||||
.SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins");
|
||||
|
||||
Assert.NotNull(adminMapping);
|
||||
Assert.Equal("Admin", adminMapping.Role);
|
||||
Assert.Equal(1, adminMapping.Id);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_WithConnectionString_RegistersRepositoriesAndServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddConfigurationDatabase("DataSource=:memory:");
|
||||
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository));
|
||||
}
|
||||
|
||||
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly
|
||||
// from source — that is the compile-time guard. Invoke it via reflection to verify the
|
||||
// runtime defence-in-depth behaviour.
|
||||
private static MethodInfo NoArgOverload =>
|
||||
typeof(ServiceCollectionExtensions).GetMethod(
|
||||
nameof(ServiceCollectionExtensions.AddConfigurationDatabase),
|
||||
BindingFlags.Public | BindingFlags.Static,
|
||||
binder: null,
|
||||
types: new[] { typeof(IServiceCollection) },
|
||||
modifiers: null)!;
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-003: the parameterless overload must not
|
||||
// silently register nothing. Misuse must surface immediately at wire-up time with an
|
||||
// actionable message — not later as an opaque DI resolution failure.
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var invocation = Assert.Throws<TargetInvocationException>(
|
||||
() => NoArgOverload.Invoke(null, new object[] { services }));
|
||||
|
||||
var ex = Assert.IsType<InvalidOperationException>(invocation.InnerException);
|
||||
Assert.Contains("connection string", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError()
|
||||
{
|
||||
// The no-op overload must be flagged so misuse is caught at compile time.
|
||||
var obsolete = NoArgOverload.GetCustomAttribute<ObsoleteAttribute>();
|
||||
Assert.NotNull(obsolete);
|
||||
Assert.True(obsolete!.IsError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
///
|
||||
/// Constructed with an explicit ephemeral Data Protection provider so secret-bearing
|
||||
/// columns are write-capable in tests. The schema-only no-provider constructor would
|
||||
/// throw on a secret-column write (ConfigurationDatabase-013); passing a provider here
|
||||
/// makes the test fixture's intent explicit at the call site.
|
||||
/// </summary>
|
||||
public class SqliteTestDbContext : ScadaBridgeDbContext
|
||||
{
|
||||
public SqliteTestDbContext(DbContextOptions<ScadaBridgeDbContext> options)
|
||||
: base(options, new EphemeralDataProtectionProvider())
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
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"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
|
||||
var nullableConverter = new ValueConverter<DateTimeOffset?, string?>(
|
||||
v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null,
|
||||
v => v != null ? DateTimeOffset.Parse(v) : null);
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
if (property.ClrType == typeof(DateTimeOffset))
|
||||
{
|
||||
property.SetValueConverter(converter);
|
||||
property.SetColumnType("TEXT");
|
||||
}
|
||||
else if (property.ClrType == typeof(DateTimeOffset?))
|
||||
{
|
||||
property.SetValueConverter(nullableConverter);
|
||||
property.SetColumnType("TEXT");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class SqliteTestHelper
|
||||
{
|
||||
public static ScadaBridgeDbContext CreateInMemoryContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
var context = new SqliteTestDbContext(options);
|
||||
context.Database.OpenConnection();
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
public static ScadaBridgeDbContext CreateFileContext(string dbPath)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite($"DataSource={dbPath}")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
var context = new SqliteTestDbContext(options);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class TemplateEngineRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly TemplateEngineRepository _repository;
|
||||
|
||||
public TemplateEngineRepositoryTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaBridgeDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
_repository = new TemplateEngineRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated()
|
||||
{
|
||||
// Arrange: a template with one attribute, one alarm, one script and one composition.
|
||||
var composed = new Template("ComposedTemplate");
|
||||
_context.Templates.Add(composed);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var template = new Template("ParentTemplate");
|
||||
template.Attributes.Add(new TemplateAttribute("Attr1"));
|
||||
template.Alarms.Add(new TemplateAlarm("Alarm1"));
|
||||
template.Scripts.Add(new TemplateScript("Script1", "return 1;"));
|
||||
template.Compositions.Add(new TemplateComposition("Slot1") { ComposedTemplateId = composed.Id });
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var loaded = await _repository.GetTemplateWithChildrenAsync(template.Id);
|
||||
|
||||
// Assert: the method must deliver the template's child members to the caller,
|
||||
// not silently drop them. Regression guard for ConfigurationDatabase-001.
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(template.Id, loaded!.Id);
|
||||
Assert.Single(loaded.Attributes);
|
||||
Assert.Equal("Attr1", loaded.Attributes.First().Name);
|
||||
Assert.Single(loaded.Alarms);
|
||||
Assert.Equal("Alarm1", loaded.Alarms.First().Name);
|
||||
Assert.Single(loaded.Scripts);
|
||||
Assert.Equal("Script1", loaded.Scripts.First().Name);
|
||||
Assert.Single(loaded.Compositions);
|
||||
Assert.Equal("Slot1", loaded.Compositions.First().InstanceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist()
|
||||
{
|
||||
var loaded = await _repository.GetTemplateWithChildrenAsync(9999);
|
||||
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplatesWithChildrenAsync_BulkVariant_FetchesEveryMatchingNameInOneQuery()
|
||||
{
|
||||
// Transport-008 regression: BundleImporter.PreviewAsync previously
|
||||
// called GetTemplateWithChildrenAsync(stub.Id) per matching template
|
||||
// name. The bulk variant returns every match in a single query.
|
||||
var a = new Template("Alpha");
|
||||
a.Attributes.Add(new TemplateAttribute("A1"));
|
||||
a.Scripts.Add(new TemplateScript("AS1", "return 1;"));
|
||||
var b = new Template("Beta");
|
||||
b.Alarms.Add(new TemplateAlarm("BAlarm"));
|
||||
var c = new Template("Gamma");
|
||||
_context.Templates.AddRange(a, b, c);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _repository.GetTemplatesWithChildrenAsync(new[] { "Alpha", "Beta", "DoesNotExist" });
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
var loadedA = Assert.Single(result, t => t.Name == "Alpha");
|
||||
var loadedB = Assert.Single(result, t => t.Name == "Beta");
|
||||
Assert.Single(loadedA.Attributes);
|
||||
Assert.Equal("A1", loadedA.Attributes.First().Name);
|
||||
Assert.Single(loadedA.Scripts);
|
||||
Assert.Single(loadedB.Alarms);
|
||||
Assert.Equal("BAlarm", loadedB.Alarms.First().Name);
|
||||
Assert.DoesNotContain(result, t => t.Name == "Gamma");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplatesWithChildrenAsync_EmptyNames_ReturnsEmpty()
|
||||
{
|
||||
var result = await _repository.GetTemplatesWithChildrenAsync(Array.Empty<string>());
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplatesWithChildrenAsync_NullEnumerable_ReturnsEmpty()
|
||||
{
|
||||
var result = await _repository.GetTemplatesWithChildrenAsync(null!);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplatesWithChildrenAsync_FiltersOutDuplicatesAndEmptyStrings()
|
||||
{
|
||||
var a = new Template("Alpha");
|
||||
_context.Templates.Add(a);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Duplicate names + empty / null entries should not throw; the helper
|
||||
// deduplicates and filters them out before the SQL IN clause.
|
||||
var result = await _repository.GetTemplatesWithChildrenAsync(
|
||||
new[] { "Alpha", "Alpha", "", null!, "Alpha" });
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Alpha", result[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk()
|
||||
{
|
||||
// FlatteningPipeline.BuildTemplateChainAsync walks ParentTemplateId upward.
|
||||
// The result of GetTemplateWithChildrenAsync must carry that link intact.
|
||||
var baseTemplate = new Template("BaseTemplate");
|
||||
_context.Templates.Add(baseTemplate);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var derived = new Template("DerivedTemplate") { ParentTemplateId = baseTemplate.Id };
|
||||
_context.Templates.Add(derived);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetTemplateWithChildrenAsync(derived.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(baseTemplate.Id, loaded!.ParentTemplateId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
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;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
public class DbContextTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public DbContextTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schema_CreatesAllTables()
|
||||
{
|
||||
// Verify all DbSet tables exist by checking we can query them without error
|
||||
Assert.NotNull(_context.Templates);
|
||||
Assert.NotNull(_context.TemplateAttributes);
|
||||
Assert.NotNull(_context.TemplateAlarms);
|
||||
Assert.NotNull(_context.TemplateScripts);
|
||||
Assert.NotNull(_context.TemplateCompositions);
|
||||
Assert.NotNull(_context.Instances);
|
||||
Assert.NotNull(_context.InstanceAttributeOverrides);
|
||||
Assert.NotNull(_context.InstanceConnectionBindings);
|
||||
Assert.NotNull(_context.Areas);
|
||||
Assert.NotNull(_context.Sites);
|
||||
Assert.NotNull(_context.DataConnections);
|
||||
Assert.NotNull(_context.DeploymentRecords);
|
||||
Assert.NotNull(_context.SystemArtifactDeploymentRecords);
|
||||
Assert.NotNull(_context.ExternalSystemDefinitions);
|
||||
Assert.NotNull(_context.ExternalSystemMethods);
|
||||
Assert.NotNull(_context.DatabaseConnectionDefinitions);
|
||||
Assert.NotNull(_context.NotificationLists);
|
||||
Assert.NotNull(_context.NotificationRecipients);
|
||||
Assert.NotNull(_context.SmtpConfigurations);
|
||||
Assert.NotNull(_context.SharedScripts);
|
||||
Assert.NotNull(_context.LdapGroupMappings);
|
||||
Assert.NotNull(_context.SiteScopeRules);
|
||||
Assert.NotNull(_context.ApiKeys);
|
||||
Assert.NotNull(_context.ApiMethods);
|
||||
Assert.NotNull(_context.AuditLogEntries);
|
||||
|
||||
// Verify we can enumerate all tables (schema is valid)
|
||||
Assert.Empty(_context.Templates.ToList());
|
||||
Assert.Empty(_context.Sites.ToList());
|
||||
Assert.Empty(_context.Instances.ToList());
|
||||
Assert.Empty(_context.AuditLogEntries.ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_WithChildren_CascadeCreated()
|
||||
{
|
||||
var template = new Template("TestTemplate")
|
||||
{
|
||||
Description = "A test template"
|
||||
};
|
||||
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Int32 });
|
||||
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.ValueMatch, PriorityLevel = 1 });
|
||||
template.Scripts.Add(new TemplateScript("Script1", "return 42;"));
|
||||
|
||||
_context.Templates.Add(template);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Single(t => t.Name == "TestTemplate");
|
||||
|
||||
Assert.Single(loaded.Attributes);
|
||||
Assert.Single(loaded.Alarms);
|
||||
Assert.Single(loaded.Scripts);
|
||||
Assert.Equal("Attr1", loaded.Attributes.First().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_Inheritance_SelfReference()
|
||||
{
|
||||
var parent = new Template("ParentTemplate");
|
||||
_context.Templates.Add(parent);
|
||||
_context.SaveChanges();
|
||||
|
||||
var child = new Template("ChildTemplate") { ParentTemplateId = parent.Id };
|
||||
_context.Templates.Add(child);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Templates.Single(t => t.Name == "ChildTemplate");
|
||||
Assert.Equal(parent.Id, loaded.ParentTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_Composition_CreatesRelationship()
|
||||
{
|
||||
var composedTemplate = new Template("ComposedTemplate");
|
||||
var parentTemplate = new Template("ParentTemplate");
|
||||
_context.Templates.AddRange(composedTemplate, parentTemplate);
|
||||
_context.SaveChanges();
|
||||
|
||||
parentTemplate.Compositions.Add(new TemplateComposition("Module1") { ComposedTemplateId = composedTemplate.Id });
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Templates
|
||||
.Include(t => t.Compositions)
|
||||
.Single(t => t.Name == "ParentTemplate");
|
||||
|
||||
Assert.Single(loaded.Compositions);
|
||||
Assert.Equal(composedTemplate.Id, loaded.Compositions.First().ComposedTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_WithOverridesAndBindings()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
var template = new Template("Template1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
_context.SaveChanges();
|
||||
|
||||
var dataConn = new DataConnection("OpcConn", "OpcUa", site.Id);
|
||||
_context.DataConnections.Add(dataConn);
|
||||
_context.SaveChanges();
|
||||
|
||||
var instance = new Instance("Instance1")
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
SiteId = site.Id,
|
||||
State = InstanceState.Enabled
|
||||
};
|
||||
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Attr1") { OverrideValue = "42" });
|
||||
instance.ConnectionBindings.Add(new InstanceConnectionBinding("TagPath") { DataConnectionId = dataConn.Id });
|
||||
_context.Instances.Add(instance);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.Single(i => i.UniqueName == "Instance1");
|
||||
|
||||
Assert.Single(loaded.AttributeOverrides);
|
||||
Assert.Single(loaded.ConnectionBindings);
|
||||
Assert.Equal(InstanceState.Enabled, loaded.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentRecord_CreatesWithAllFields()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
var template = new Template("Template1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
_context.SaveChanges();
|
||||
|
||||
var instance = new Instance("Instance1") { TemplateId = template.Id, SiteId = site.Id, State = InstanceState.Enabled };
|
||||
_context.Instances.Add(instance);
|
||||
_context.SaveChanges();
|
||||
|
||||
var record = new DeploymentRecord("deploy-001", "admin")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
Status = DeploymentStatus.Success,
|
||||
RevisionHash = "abc123",
|
||||
DeployedAt = DateTimeOffset.UtcNow,
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_context.DeploymentRecords.Add(record);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.DeploymentRecords.Single(d => d.DeploymentId == "deploy-001");
|
||||
Assert.Equal(DeploymentStatus.Success, loaded.Status);
|
||||
Assert.Equal("abc123", loaded.RevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogEntry_WritesAndQueries()
|
||||
{
|
||||
var entry = new AuditLogEntry("admin", "Create", "Template", "1", "TestTemplate")
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
AfterStateJson = "{\"name\":\"TestTemplate\"}"
|
||||
};
|
||||
_context.AuditLogEntries.Add(entry);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.AuditLogEntries.Single(a => a.User == "admin");
|
||||
Assert.Equal("Create", loaded.Action);
|
||||
Assert.Equal("Template", loaded.EntityType);
|
||||
Assert.NotNull(loaded.AfterStateJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalSystem_WithMethods()
|
||||
{
|
||||
var system = new ExternalSystemDefinition("ERP", "https://erp.example.com/api", "ApiKey")
|
||||
{
|
||||
MaxRetries = 3,
|
||||
RetryDelay = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
_context.ExternalSystemDefinitions.Add(system);
|
||||
_context.SaveChanges();
|
||||
|
||||
var method = new ExternalSystemMethod("GetOrder", "GET", "/orders/{id}")
|
||||
{
|
||||
ExternalSystemDefinitionId = system.Id
|
||||
};
|
||||
_context.ExternalSystemMethods.Add(method);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.ExternalSystemMethods.Where(m => m.ExternalSystemDefinitionId == system.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationList_WithRecipients()
|
||||
{
|
||||
var list = new NotificationList("Operators");
|
||||
list.Recipients.Add(new NotificationRecipient("John", "john@example.com"));
|
||||
list.Recipients.Add(new NotificationRecipient("Jane", "jane@example.com"));
|
||||
|
||||
_context.NotificationLists.Add(list);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.NotificationLists
|
||||
.Include(n => n.Recipients)
|
||||
.Single(n => n.Name == "Operators");
|
||||
|
||||
Assert.Equal(2, loaded.Recipients.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Security_LdapGroupMapping_WithSiteScopeRules()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
_context.SaveChanges();
|
||||
|
||||
var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Admin");
|
||||
_context.LdapGroupMappings.Add(mapping);
|
||||
_context.SaveChanges();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
_context.SiteScopeRules.Add(rule);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.SiteScopeRules.Where(r => r.LdapGroupMappingId == mapping.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InboundApi_ApiKeyAndMethod()
|
||||
{
|
||||
var key = new ApiKey("TestKey", "sk-test-123") { IsEnabled = true };
|
||||
var method = new ApiMethod("GetStatus", "return \"ok\";") { TimeoutSeconds = 30 };
|
||||
|
||||
_context.ApiKeys.Add(key);
|
||||
_context.ApiMethods.Add(method);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.ApiKeys.Where(k => k.Name == "TestKey"));
|
||||
Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Area_HierarchyWorks()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
_context.SaveChanges();
|
||||
|
||||
var parentArea = new Area("Building A") { SiteId = site.Id };
|
||||
_context.Areas.Add(parentArea);
|
||||
_context.SaveChanges();
|
||||
|
||||
var childArea = new Area("Floor 1") { SiteId = site.Id, ParentAreaId = parentArea.Id };
|
||||
_context.Areas.Add(childArea);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Areas
|
||||
.Include(a => a.Children)
|
||||
.Single(a => a.Name == "Building A");
|
||||
|
||||
Assert.Single(loaded.Children);
|
||||
Assert.Equal("Floor 1", loaded.Children.First().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataConnection_BelongsToSite()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
_context.SaveChanges();
|
||||
|
||||
var conn = new DataConnection("OpcConn", "OpcUa", site.Id);
|
||||
_context.DataConnections.Add(conn);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.DataConnections.Single(c => c.Name == "OpcConn");
|
||||
Assert.Equal(site.Id, loaded.SiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumProperties_StoredAsStrings()
|
||||
{
|
||||
var template = new Template("EnumTest");
|
||||
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Double });
|
||||
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.RangeViolation, PriorityLevel = 1 });
|
||||
_context.Templates.Add(template);
|
||||
_context.SaveChanges();
|
||||
|
||||
// Query using raw SQL to verify string storage
|
||||
var attr = _context.TemplateAttributes.Single(a => a.Name == "Attr1");
|
||||
Assert.Equal(DataType.Double, attr.DataType);
|
||||
|
||||
var alarm = _context.TemplateAlarms.Single(a => a.Name == "Alarm1");
|
||||
Assert.Equal(AlarmTriggerType.RangeViolation, alarm.TriggerType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UniqueConstraint_Template_Name_Enforced()
|
||||
{
|
||||
_context.Templates.Add(new Template("Unique"));
|
||||
_context.SaveChanges();
|
||||
|
||||
_context.Templates.Add(new Template("Unique"));
|
||||
Assert.ThrowsAny<Exception>(() => _context.SaveChanges());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTimeOffset_MappedCorrectly()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = new AuditLogEntry("user", "Test", "Entity", "1", "Name") { Timestamp = now };
|
||||
_context.AuditLogEntries.Add(entry);
|
||||
_context.SaveChanges();
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = _context.AuditLogEntries.Single();
|
||||
// SQLite has limited DateTimeOffset precision, but the round-trip should preserve the value within a second
|
||||
Assert.True(Math.Abs((loaded.Timestamp - now).TotalSeconds) < 1);
|
||||
}
|
||||
}
|
||||
|
||||
public class ServiceRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_WithConnectionString_RegistersDbContext()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddConfigurationDatabase("DataSource=:memory:");
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var context = provider.GetService<ScadaBridgeDbContext>();
|
||||
|
||||
Assert.NotNull(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_NoArgs_FailsFast()
|
||||
{
|
||||
// ConfigurationDatabase-003: the no-arg overload previously silently registered
|
||||
// nothing, which deferred a misconfiguration into an opaque DI failure later.
|
||||
// It is now [Obsolete(error: true)] (compile-time guard) and throws at runtime.
|
||||
// Invoked via reflection because the obsolete-error overload cannot be called
|
||||
// directly from source.
|
||||
var method = typeof(ServiceCollectionExtensions).GetMethod(
|
||||
nameof(ServiceCollectionExtensions.AddConfigurationDatabase),
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static,
|
||||
binder: null,
|
||||
types: new[] { typeof(IServiceCollection) },
|
||||
modifiers: null)!;
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var invocation = Assert.Throws<System.Reflection.TargetInvocationException>(
|
||||
() => method.Invoke(null, new object[] { services }));
|
||||
Assert.IsType<InvalidOperationException>(invocation.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
public class MigrationHelperTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
|
||||
public MigrationHelperTests()
|
||||
{
|
||||
// Use SQLite with PendingModelChangesWarning suppressed because the migration
|
||||
// was generated for SQL Server and SQLite's model representation differs slightly.
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
_context = new ScadaBridgeDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyOrValidate_ProductionMode_WithPendingMigrations_Throws()
|
||||
{
|
||||
// Database has no schema yet, so pending migrations exist.
|
||||
// The production path uses GetPendingMigrationsAsync which works cross-provider.
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => MigrationHelper.ApplyOrValidateMigrationsAsync(_context, isDevelopment: false));
|
||||
|
||||
Assert.Contains("pending migration", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrationExists_InitialCreate()
|
||||
{
|
||||
// Verify the InitialCreate migration is detected as pending
|
||||
var pending = _context.Database.GetPendingMigrations().ToList();
|
||||
Assert.Contains(pending, m => m.Contains("InitialSchema"));
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<!--
|
||||
Bundle C migration integration tests need Microsoft.Data.SqlClient. EF
|
||||
SqlServer 10.0.7 pulls in SqlClient >= 6.1.1, but the central package
|
||||
version is pinned at 6.0.2 (the version the production
|
||||
ExternalSystemGateway uses). Override the version locally for the test
|
||||
project only; production assemblies are unaffected.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<!--
|
||||
SkippableFact lets the Bundle C MSSQL integration tests report as Skipped
|
||||
(not Passed) when the dev MSSQL container is not running. xunit 2.9.x does
|
||||
not ship Assert.Skip / SkipUnless — those are v3-only — so we use the
|
||||
canonical community wrapper instead.
|
||||
-->
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj" />
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user