docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -3,7 +3,7 @@ using System.Text.RegularExpressions;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Code-level guard for the AuditLog append-only invariant (task M2.10, #18).
/// Code-level guard for the AuditLog append-only invariant.
///
/// The DB-role control (DENY UPDATE / DENY DELETE on dbo.AuditLog in migration
/// 20260602174346_CollapseAuditLogToCanonical) is the runtime enforcement layer.
@@ -36,12 +36,12 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <see cref="AuditPurgeAllowedMarker"/> trailing comment:
/// <list type="bullet">
/// <item><description>
/// M5.5 (T3) — <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>: the
/// <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>: the
/// one sanctioned batched <c>DELETE TOP (@batch) FROM dbo.AuditLog</c>,
/// running on the purge/maintenance connection.
/// </description></item>
/// <item><description>
/// M5.6 (T5) — <c>AuditLogRepository.BackfillSourceNodeAsync</c>: the
/// <c>AuditLogRepository.BackfillSourceNodeAsync</c>: the
/// one sanctioned batched <c>UPDATE TOP (@batch) dbo.AuditLog SET SourceNode</c>,
/// running on the maintenance connection. The sentinel backfill is a
/// one-time ops procedure; the append-only invariant still applies to all
@@ -60,8 +60,8 @@ public class AuditLogAppendOnlyGuardTests
/// The exact trailing-comment marker that exempts a single sanctioned
/// maintenance-path DML line from the append-only guard. Carried at the END of
/// the SQL constant string in both <c>AuditLogRepository.PurgeChannelOlderThanAsync</c>
/// (M5.5 T3 batched DELETE) and <c>AuditLogRepository.BackfillSourceNodeAsync</c>
/// (M5.6 T5 batched UPDATE). Kept deliberately specific so it cannot be pasted
/// (batched DELETE) and <c>AuditLogRepository.BackfillSourceNodeAsync</c>
/// (batched UPDATE). Kept deliberately specific so it cannot be pasted
/// onto an unrelated mutation without a reviewer noticing.
/// </summary>
internal const string AuditPurgeAllowedMarker = "AUDIT-PURGE-ALLOWED";
@@ -166,7 +166,7 @@ public class AuditLogAppendOnlyGuardTests
// The DELETE branch tolerates an optional TOP (...) batch-size clause between
// DELETE and the (optional) FROM — e.g. "DELETE TOP (@batch) FROM dbo.AuditLog"
// (the M5.5 T3 batched purge shape). Without this the guard would silently miss a
// (the batched purge shape). Without this the guard would silently miss a
// batched row DELETE against AuditLog, which is exactly the kind of mutation it
// must catch. The TOP sub-pattern is (?:TOP\s*\(.*?\)\s+)? — optional, lazy inside
// the parens so it never swallows past the matching ')'.
@@ -174,7 +174,7 @@ public class AuditLogAppendOnlyGuardTests
// The UPDATE branch similarly tolerates an optional TOP (...) clause between
// UPDATE and (optional schema.) AuditLog — e.g.
// "UPDATE TOP (@batch) dbo.AuditLog SET SourceNode = @sentinel …"
// (the M5.6 T5 batched backfill shape).
// (the batched backfill shape).
private static readonly Regex AuditLogMutationPattern = new(
@"\bUPDATE\s+(?:TOP\s*\(.*?\)\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b" +
@"|\bDELETE\s+(?:TOP\s*\(.*?\)\s+)?(?:FROM\s+)?(?:\[?dbo\]?\.)?(?:\[?AuditLog\]?)\b",
@@ -184,8 +184,8 @@ public class AuditLogAppendOnlyGuardTests
/// Returns <see langword="true"/> when <paramref name="line"/> carries the narrow
/// <see cref="AuditPurgeAllowedMarker"/> exemption. Sanctioned uses are:
/// <list type="bullet">
/// <item><description>M5.5 T3 — the per-channel maintenance-path batched DELETE.</description></item>
/// <item><description>M5.6 T5 — the SourceNode sentinel batched UPDATE.</description></item>
/// <item><description>The per-channel maintenance-path batched DELETE.</description></item>
/// <item><description>The SourceNode sentinel batched UPDATE.</description></item>
/// </list>
/// A flagged line that lacks the marker is NOT allow-listed. The mutation matcher
/// itself stays marker-blind; the allow-list is applied only by the file-scan test,
@@ -201,6 +201,7 @@ public class AuditLogAppendOnlyGuardTests
// Designer/Snapshot EF artefacts and the obj/ directory).
// ---------------------------------------------------------------------------
/// <summary>Verifies that no source file in the ConfigurationDatabase project contains an unsanctioned UPDATE or DELETE against the AuditLog table.</summary>
[Fact]
public void ConfigurationDatabase_ShouldNotContainAuditLogMutations()
{
@@ -245,6 +246,7 @@ public class AuditLogAppendOnlyGuardTests
// Self-verifying matcher unit tests — prove the helper does what it claims.
// ---------------------------------------------------------------------------
/// <summary>Verifies that the mutation matcher returns false for source lines that do not mutate AuditLog.</summary>
[Fact]
public void ContainsAuditLogMutation_ReturnsFalse_ForCleanSource()
{
@@ -296,6 +298,7 @@ public class AuditLogAppendOnlyGuardTests
"TRUNCATE TABLE dbo.AuditLog_Staging_abc123;"));
}
/// <summary>Verifies that the mutation matcher returns true for planted UPDATE and DELETE violations against AuditLog.</summary>
[Fact]
public void ContainsAuditLogMutation_ReturnsTrue_ForPlantedViolations()
{
@@ -344,7 +347,7 @@ public class AuditLogAppendOnlyGuardTests
Assert.True(ContainsAuditLogMutation(
"DELETE FROM [AuditLog] WHERE OccurredAtUtc < @threshold;"));
// ---- Batched DELETE TOP (...) forms (M5.5 T3 purge shape) ----
// ---- Batched DELETE TOP (...) forms (purge shape) ----
// The matcher must catch a batched DELETE against AuditLog regardless of the
// marker — the allow-list (IsAllowListed) is what forgives the ONE sanctioned
// line, not the matcher.
@@ -355,7 +358,7 @@ public class AuditLogAppendOnlyGuardTests
Assert.True(ContainsAuditLogMutation(
"DELETE TOP(100) FROM [dbo].[AuditLog] WHERE Status = 'Parked';"));
// ---- Batched UPDATE TOP (...) forms (M5.6 T5 backfill shape) ----
// ---- Batched UPDATE TOP (...) forms (backfill shape) ----
// The matcher must also catch a batched UPDATE against AuditLog, regardless of
// the marker — the allow-list is what forgives the ONE sanctioned backfill line.
Assert.True(ContainsAuditLogMutation(
@@ -366,6 +369,7 @@ public class AuditLogAppendOnlyGuardTests
"UPDATE TOP(100) [dbo].[AuditLog] SET SourceNode = @s WHERE SourceNode IS NULL;"));
}
/// <summary>Verifies that the mutation matcher returns false for DENY, GRANT and partition-switch statements.</summary>
[Fact]
public void ContainsAuditLogMutation_ReturnsFalse_ForDenyGrantAndPartitionSwitchSamples()
{
@@ -396,10 +400,11 @@ public class AuditLogAppendOnlyGuardTests
}
// ---------------------------------------------------------------------------
// Allow-list self-tests (M5.5 T3 / M5.6 T5) — prove the narrow exemption only
// Allow-list self-tests — prove the narrow exemption only
// forgives the marked maintenance-path DML and still blocks everything else.
// ---------------------------------------------------------------------------
/// <summary>Verifies that the allow-list forgives a marked purge DELETE while the raw matcher still flags it.</summary>
[Fact]
public void AllowList_ForgivesMarkedPurgeDelete_ButMatcherStillTrips()
{
@@ -415,6 +420,7 @@ public class AuditLogAppendOnlyGuardTests
Assert.True(IsAllowListed(sanctioned));
}
/// <summary>Verifies that the allow-list forgives a marked backfill UPDATE while the raw matcher still flags it.</summary>
[Fact]
public void AllowList_ForgivesMarkedBackfillUpdate_ButMatcherStillTrips()
{
@@ -430,6 +436,7 @@ public class AuditLogAppendOnlyGuardTests
Assert.True(IsAllowListed(sanctioned));
}
/// <summary>Verifies that the allow-list does not forgive a stray DELETE that lacks the marker.</summary>
[Fact]
public void AllowList_DoesNotForgive_UnmarkedStrayDelete()
{
@@ -443,6 +450,7 @@ public class AuditLogAppendOnlyGuardTests
"A DELETE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
}
/// <summary>Verifies that the allow-list does not forgive a stray UPDATE that lacks the marker.</summary>
[Fact]
public void AllowList_DoesNotForgive_UnmarkedStrayUpdate()
{
@@ -454,6 +462,7 @@ public class AuditLogAppendOnlyGuardTests
"An UPDATE against AuditLog without the AUDIT-PURGE-ALLOWED marker must NOT be allow-listed.");
}
/// <summary>Verifies that the allow-list does not forgive a batched UPDATE that lacks the marker.</summary>
[Fact]
public void AllowList_DoesNotForgive_BatchedUpdateWithoutMarker()
{
@@ -14,6 +14,7 @@ public class AuditServiceTests : IDisposable
private readonly AuditCorrelationContext _correlationContext;
private readonly AuditService _auditService;
/// <summary>Initializes the test class with an in-memory SQLite database and an audit service.</summary>
public AuditServiceTests()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -27,12 +28,15 @@ public class AuditServiceTests : IDisposable
_auditService = new AuditService(_context, _correlationContext);
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that logging creates an audit entry committed together with the entity change.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_CreatesAuditEntry_CommittedWithEntityChange()
{
@@ -56,6 +60,8 @@ public class AuditServiceTests : IDisposable
Assert.Single(await _context.Templates.ToListAsync());
}
/// <summary>Verifies that a rollback undoes both the entity change and its audit entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_Rollback_BothChangeAndAuditRolledBack()
{
@@ -78,6 +84,8 @@ public class AuditServiceTests : IDisposable
Assert.Empty(await _context.Templates.Where(t => t.Name == "RollbackTemplate").ToListAsync());
}
/// <summary>Verifies that the after-state is serialized as JSON.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_SerializesAfterStateAsJson()
{
@@ -93,6 +101,8 @@ public class AuditServiceTests : IDisposable
Assert.Equal(42, deserialized.GetProperty("Value").GetInt32());
}
/// <summary>Verifies that a null after-state is stored for deletes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_NullAfterState_ForDeletes()
{
@@ -104,6 +114,8 @@ public class AuditServiceTests : IDisposable
Assert.Equal("Delete", audit.Action);
}
/// <summary>Verifies that the audit timestamp is set to the current UTC time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_SetsTimestampToUtcNow()
{
@@ -118,6 +130,7 @@ public class AuditServiceTests : IDisposable
Assert.True(audit.Timestamp <= after.AddSeconds(2));
}
/// <summary>Verifies that the audit service is append-only and exposes no update or delete methods.</summary>
[Fact]
public void AuditService_IsAppendOnly_NoUpdateOrDeleteMethods()
{
@@ -128,6 +141,8 @@ public class AuditServiceTests : IDisposable
Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that the bundle import id is stamped from the correlation context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_StampsBundleImportId_FromCorrelationContext()
{
@@ -145,6 +160,8 @@ public class AuditServiceTests : IDisposable
Assert.Equal(bundleId, audit.BundleImportId);
}
/// <summary>Verifies that the bundle import id stays null when the correlation context has no value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_LeavesBundleImportIdNull_WhenCorrelationContextHasNoValue()
{
@@ -164,14 +181,18 @@ public class AuditServiceTests : IDisposable
// Self-referential POCO used to reproduce a reference cycle in afterState.
private sealed class CyclicNode
{
/// <summary>Gets or sets the node name.</summary>
public string Name { get; set; } = "node";
/// <summary>Gets or sets the self-referencing node that forms the cycle.</summary>
public CyclicNode? Self { get; set; }
}
/// <summary>Verifies that an after-state containing a reference cycle neither throws nor rolls back the audited operation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogAsync_AfterStateWithReferenceCycle_DoesNotThrow_AndDoesNotRollBackOperation()
{
// Regression guard for ConfigurationDatabase-007: serializing an afterState
// Regression guard for 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();
@@ -17,8 +17,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// </summary>
public class ConcurrencyTestDbContext : ScadaBridgeDbContext
{
/// <summary>Initializes the context with the given options.</summary>
/// <param name="options">The context options.</param>
public ConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { }
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -41,13 +44,16 @@ public class ConcurrencyTestDbContext : ScadaBridgeDbContext
/// 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
/// is sufficient to exercise the production stub-attach delete path under the
/// concurrency rule.
/// </summary>
public class RowVersionConcurrencyTestDbContext : ScadaBridgeDbContext
{
/// <summary>Initializes the context with the given options.</summary>
/// <param name="options">The context options.</param>
public RowVersionConcurrencyTestDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options) { }
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -66,11 +72,13 @@ public class ConcurrencyTests : IDisposable
{
private readonly string _dbPath;
/// <summary>Creates a unique temporary SQLite database path for each test.</summary>
public ConcurrencyTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
if (File.Exists(_dbPath))
@@ -86,6 +94,8 @@ public class ConcurrencyTests : IDisposable
return new ConcurrencyTestDbContext(options);
}
/// <summary>Verifies that a second update of a deployment record from a stale copy throws a concurrency exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploymentRecord_OptimisticConcurrency_SecondUpdateThrows()
{
@@ -138,6 +148,8 @@ public class ConcurrencyTests : IDisposable
() => ctx2.SaveChangesAsync());
}
/// <summary>Verifies that templates have no optimistic concurrency, so the last write wins.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Template_NoOptimisticConcurrency_LastWriteWins()
{
@@ -175,10 +187,12 @@ public class ConcurrencyTests : IDisposable
Assert.Equal("Second update", loaded.Description);
}
/// <summary>Verifies that deleting a deployment record with a stale row version throws a concurrency exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteDeploymentRecord_StaleRowVersion_ThrowsConcurrencyException()
{
// CD-017: Verifies the stub-attach delete path enforces optimistic concurrency
// 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).
@@ -232,6 +246,7 @@ public class ConcurrencyTests : IDisposable
.Options;
}
/// <summary>Verifies that the <c>DeploymentRecord</c> row version is configured as a concurrency token.</summary>
[Fact]
public void DeploymentRecord_HasRowVersionConfigured()
{
@@ -8,7 +8,7 @@ using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;
/// <summary>
/// Schema-level tests for the C5 (Task 2.5) <see cref="AuditLogEntityTypeConfiguration"/>.
/// Schema-level tests for the C5 <see cref="AuditLogEntityTypeConfiguration"/>.
/// After the central <c>dbo.AuditLog</c> collapse the table is the 10 canonical
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> columns plus six read-only <b>persisted computed
/// columns</b> derived from <c>DetailsJson</c>. Verifies the PK, the canonical/computed
@@ -22,17 +22,20 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Creates the test fixture and its database context.</summary>
public AuditLogEntityTypeConfigurationTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
/// <summary>Closes the connection and disposes the database context.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that the configuration maps to the AuditLog table with a composite primary key.</summary>
[Fact]
public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey()
{
@@ -51,10 +54,11 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.Equal(new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, pkPropertyNames);
}
/// <summary>Verifies that no non-aligned EventId index is declared and uniqueness rides the aligned clustered key.</summary>
[Fact]
public void Configure_DeclaresNoNonAlignedEventIdIndex_UniquenessRidesTheAlignedClusteredKey()
{
// WP2.2 (AlignAuditLogEventIdUniqueness): the standalone non-aligned
// AlignAuditLogEventIdUniqueness: the standalone non-aligned
// UX_AuditLog_EventId is gone. EventId uniqueness now rides the clustered
// PK (EventId, OccurredAtUtc), which is partition-aligned on
// ps_AuditLog_Month — so ALTER TABLE ... SWITCH PARTITION no longer needs
@@ -79,6 +83,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
pk!.Properties.Select(p => p.Name).ToArray());
}
/// <summary>Verifies that the configuration maps the expected number of properties.</summary>
[Fact]
public void Configure_HasExpectedPropertyCount()
{
@@ -96,6 +101,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.Equal(16, properties.Count);
}
/// <summary>Verifies that the channel property maps to the canonical category column.</summary>
[Fact]
public void Configure_ChannelMapsToCanonicalCategoryColumn()
{
@@ -109,6 +115,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.Equal("Category", channel!.GetColumnName());
}
/// <summary>Verifies that each computed column has the expected persisted SQL expression.</summary>
/// <param name="propertyName">The name of the computed property.</param>
/// <param name="expectedSql">The expected computed-column SQL.</param>
[Theory]
[InlineData(nameof(AuditLogRow.Kind), "JSON_VALUE(DetailsJson,'$.kind')")]
[InlineData(nameof(AuditLogRow.Status), "JSON_VALUE(DetailsJson,'$.status')")]
@@ -132,6 +141,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated);
}
/// <summary>Verifies that the IngestedAtUtc property is a non-persisted computed column.</summary>
[Fact]
public void Configure_IngestedAtUtc_IsNonPersistedComputedColumn()
{
@@ -150,6 +160,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.Equal(Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAddOrUpdate, property.ValueGenerated);
}
/// <summary>Verifies that the expected indexes exist with the correct names.</summary>
[Fact]
public void Configure_ExpectedIndexes_WithCorrectNames()
{
@@ -180,6 +191,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.Equal(expected, indexNames);
}
/// <summary>Verifies that each enum column is stored as a varchar(32).</summary>
/// <param name="propertyName">The name of the enum property.</param>
[Theory]
[InlineData(nameof(AuditLogRow.Kind))]
[InlineData(nameof(AuditLogRow.Status))]
@@ -198,6 +211,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.False(property.IsUnicode() ?? true);
}
/// <summary>Verifies that the outcome column is stored as a varchar(16).</summary>
[Fact]
public void Configure_Outcome_StoredAsVarchar16()
{
@@ -211,6 +225,8 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.False(property.IsUnicode() ?? true);
}
/// <summary>Verifies that the UTC converter hydrates OccurredAtUtc with a UTC kind.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
{
@@ -252,6 +268,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.Equal(unspecifiedOccurred.Ticks, loaded.OccurredAtUtc.Ticks);
}
/// <summary>Verifies that OccurredAtUtc and IngestedAtUtc have UTC value converters.</summary>
[Fact]
public void Configure_OccurredAtUtcAndIngestedAtUtc_HaveUtcValueConverters()
{
@@ -269,6 +286,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
Assert.NotNull(ingestedAt!.GetValueConverter());
}
/// <summary>Verifies that the filtered indexes have the expected filter expressions.</summary>
[Fact]
public void Configure_FilteredIndexes_HaveExpectedFilters()
{
@@ -6,28 +6,31 @@ 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).
/// Schema-level tests for <see cref="SiteCallEntityTypeConfiguration"/>.
/// 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
/// Mirrors the AuditLog 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;
/// <summary>Initializes an in-memory SQLite context for the schema tests.</summary>
public SiteCallEntityTypeConfigurationTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that the <c>SiteCall</c> entity maps to the <c>SiteCalls</c> table.</summary>
[Fact]
public void Configure_MapsToSiteCallsTable()
{
@@ -37,6 +40,7 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable
Assert.Equal("SiteCalls", entity!.GetTableName());
}
/// <summary>Verifies that the primary key is <c>TrackedOperationId</c>.</summary>
[Fact]
public void Configure_PrimaryKey_TrackedOperationId()
{
@@ -50,6 +54,7 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable
Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames);
}
/// <summary>Verifies that the expected indexes exist with the right names and column order.</summary>
[Fact]
public void Configure_HasIndexes_NamedAndOrdered()
{
@@ -71,6 +76,7 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable
Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps);
}
/// <summary>Verifies that <c>TrackedOperationId</c> is converted to a string column of length 36.</summary>
[Fact]
public void Configure_TrackedOperationId_ConvertedToString_Length36()
{
@@ -90,6 +96,9 @@ public class SiteCallEntityTypeConfigurationTests : IDisposable
Assert.False(property.IsUnicode() ?? true);
}
/// <summary>Verifies that each ASCII column is bounded to its expected maximum length.</summary>
/// <param name="propertyName">The entity property name.</param>
/// <param name="expectedMaxLength">The expected maximum column length.</param>
[Theory]
[InlineData(nameof(SiteCall.Channel), 32)]
[InlineData(nameof(SiteCall.SourceSite), 64)]
@@ -9,17 +9,20 @@ public class DataProtectionTests : IDisposable
{
private readonly string _dbPath;
/// <summary>Creates a unique temporary database path for each test.</summary>
public DataProtectionTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_dp_test_{Guid.NewGuid()}.db");
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
if (File.Exists(_dbPath))
File.Delete(_dbPath);
}
/// <summary>Verifies that data protected in one container can be unprotected in another that shares the same key store.</summary>
[Fact]
public void SharedDataProtection_ProtectAndUnprotect_AcrossContainers()
{
@@ -62,6 +62,8 @@ public class DeployedRevisionHashesRepositoryTests : IDisposable
await _context.SaveChangesAsync();
}
/// <summary>Verifies that the repository returns every deployed snapshot keyed by instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedRevisionHashes_returns_every_snapshot_keyed_by_instance()
{
@@ -77,6 +79,8 @@ public class DeployedRevisionHashesRepositoryTests : IDisposable
Assert.Equal("hash-two", hashes[second]);
}
/// <summary>Verifies that the repository returns an empty result when nothing is deployed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDeployedRevisionHashes_returns_empty_when_nothing_is_deployed()
{
@@ -36,12 +36,14 @@ public class DeploymentListQueryRepositoryTests : IDisposable
private readonly DeploymentManagerRepository _repository;
private readonly DateTimeOffset _base = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
/// <summary>Initializes a new instance of the <c>DeploymentListQueryRepositoryTests</c> class.</summary>
public DeploymentListQueryRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new DeploymentManagerRepository(_context);
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
@@ -91,6 +93,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
// ── Paging ────────────────────────────────────────────────────────────────
/// <summary>Verifies that a page query returns the requested slice together with the total count of the whole set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_ReturnsRequestedSlice_AndTheTotalCountOfTheWholeSet()
{
@@ -108,11 +112,13 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal(10, page2.Rows.Count);
Assert.Equal(3, page2.PageCount(10));
// Newest first: page 2 of 10 starts at the 11th newest, dep-014.
// Newest first: page 2 of 10 starts at the 11th newest deployment.
Assert.Equal("dep-014", page2.Rows[0].DeploymentId);
Assert.Equal("dep-005", page2.Rows[^1].DeploymentId);
}
/// <summary>Verifies that consecutive pages neither overlap nor drop rows, even when rows share a deployed-at timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_PagesDoNotOverlapOrDropRows_EvenWhenDeployedAtTies()
{
@@ -138,6 +144,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal(9, seen.Distinct().Count());
}
/// <summary>Verifies that a non-positive page number is floored at page one.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_NonPositivePageNumber_IsFlooredAtPageOne()
{
@@ -151,6 +159,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Single(page.Rows);
}
/// <summary>Verifies that an oversized page size is clamped to the maximum.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_OversizedPageSize_IsClampedToTheMaximum()
{
@@ -167,6 +177,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.True(DeploymentListPage.MaxPageSize > 0);
}
/// <summary>Verifies that a page past the end returns no rows but still reports the total.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_PastTheEnd_ReturnsNoRowsButStillReportsTheTotal()
{
@@ -183,6 +195,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
// ── Joined projection ─────────────────────────────────────────────────────
/// <summary>Verifies that the page query joins the instance so its name and site travel with each row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_JoinsTheInstance_SoNameAndSiteTravelWithTheRow()
{
@@ -205,6 +219,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
// ── Filters ───────────────────────────────────────────────────────────────
/// <summary>Verifies that the status filter is applied in the database query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_StatusFilter_IsAppliedInTheDatabase()
{
@@ -220,6 +236,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal("dep-bad", Assert.Single(page.Rows).DeploymentId);
}
/// <summary>Verifies that the instance filter restricts results to a single instance's history.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_InstanceFilter_RestrictsToOneInstanceHistory()
{
@@ -235,6 +253,10 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
}
/// <summary>Verifies that the search term matches across the identifiers a user can look up.</summary>
/// <param name="term">The term.</param>
/// <param name="expected">The expected.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("Filler", "dep-a")] // instance unique name
[InlineData("dep-b", "dep-b")] // deployment id
@@ -254,6 +276,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal(expected, Assert.Single(page.Rows).DeploymentId);
}
/// <summary>Verifies that a whitespace-only search is treated as no constraint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_WhitespaceSearch_IsTreatedAsUnconstrained()
{
@@ -267,6 +291,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal(1, page.TotalCount);
}
/// <summary>Verifies that the site scope restricts results to the granted sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_SiteScope_RestrictsToTheGrantedSites()
{
@@ -284,6 +310,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
}
/// <summary>Verifies that an empty site scope is a real filter that matches nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_EmptySiteScope_IsARealFilterThatMatchesNothing()
{
@@ -300,6 +328,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Empty(page.Rows);
}
/// <summary>Verifies that site scope and status filters compose correctly for the CLI query path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryPage_SiteScopeAndStatus_Compose_ForTheCliQueryPath()
{
@@ -333,6 +363,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
// ── Status counts ─────────────────────────────────────────────────────────
/// <summary>Verifies that status counts group every status and report absent statuses as zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StatusCounts_GroupsEveryStatus_WithAbsentOnesReportedAsZero()
{
@@ -352,6 +384,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal(4, counts.Total);
}
/// <summary>Verifies that status counts ignore the status filter so each tile keeps its own total.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StatusCounts_IgnoreTheStatusFilter_SoEachTileKeepsItsOwnTotal()
{
@@ -371,6 +405,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal(2, counts.Total);
}
/// <summary>Verifies that status counts honor every filter dimension other than status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StatusCounts_HonourEveryOtherFilterDimension()
{
@@ -396,6 +432,8 @@ public class DeploymentListQueryRepositoryTests : IDisposable
Assert.Equal(2, byInstance.Total);
}
/// <summary>Verifies that status counts over an empty set are all zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StatusCounts_EmptySet_IsAllZeros()
{
@@ -7,20 +7,23 @@ public class DesignTimeDbContextFactoryTests : IDisposable
private const string EnvVar = "SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING";
private readonly string? _originalEnv;
/// <summary>Initializes the test and captures the original connection string environment variable.</summary>
public DesignTimeDbContextFactoryTests()
{
_originalEnv = Environment.GetEnvironmentVariable(EnvVar);
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
Environment.SetEnvironmentVariable(EnvVar, _originalEnv);
}
/// <summary>Verifies that creating a context with no connection string configured throws a clear exception.</summary>
[Fact]
public void CreateDbContext_NoConnectionStringConfigured_ThrowsClearException()
{
// Regression guard for ConfigurationDatabase-002: the factory must not fall back
// Regression guard: 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);
@@ -33,6 +36,7 @@ public class DesignTimeDbContextFactoryTests : IDisposable
Assert.DoesNotContain("sa", ex.Message.Split(' '), StringComparer.OrdinalIgnoreCase);
}
/// <summary>Verifies that a connection string from the environment variable is used.</summary>
[Fact]
public void CreateDbContext_ConnectionStringFromEnvironmentVariable_IsUsed()
{
@@ -47,6 +51,7 @@ public class DesignTimeDbContextFactoryTests : IDisposable
Assert.NotNull(context);
}
/// <summary>Verifies that the factory source contains no hardcoded SA credential.</summary>
[Fact]
public void DesignTimeDbContextFactory_SourceContainsNoHardcodedSaCredential()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Regression guard for ConfigurationDatabase-013: a <see cref="ScadaBridgeDbContext"/>
/// Regression guard: 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
@@ -22,6 +22,8 @@ public class EphemeralEncryptionFallbackTests
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.Options;
/// <summary>Verifies that writing a secret column through the single-argument constructor fails fast and does not persist throwaway ciphertext.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SingleArgConstructor_WritingSecretColumn_FailsFast_DoesNotPersistThrowawayCiphertext()
{
@@ -44,6 +46,8 @@ public class EphemeralEncryptionFallbackTests
Assert.Contains("Data Protection", ex.Message);
}
/// <summary>Verifies that writing a non-secret column through the single-argument constructor succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SingleArgConstructor_WritingNonSecretColumn_Succeeds()
{
@@ -61,6 +65,8 @@ public class EphemeralEncryptionFallbackTests
Assert.True(ext.Id > 0);
}
/// <summary>Verifies that writing a secret column through the provider constructor still succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProviderConstructor_WritingSecretColumn_StillSucceeds()
{
@@ -15,18 +15,22 @@ public class InboundApiRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly InboundApiRepository _repository;
/// <summary>Initializes a new test instance with its own database context.</summary>
public InboundApiRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new InboundApiRepository(_context);
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that an added API method can be retrieved by name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddApiMethod_AndGetByName_RoundTrips()
{
@@ -39,6 +43,8 @@ public class InboundApiRepositoryTests : IDisposable
Assert.Equal(method.Id, loaded!.Id);
}
/// <summary>Verifies that deleting an API method removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteApiMethod_RemovesEntity()
{
@@ -52,6 +58,7 @@ public class InboundApiRepositoryTests : IDisposable
Assert.Null(await _repository.GetApiMethodByIdAsync(method.Id));
}
/// <summary>Verifies that constructing the repository with a null context throws.</summary>
[Fact]
public void Constructor_NullContext_Throws()
{
@@ -16,11 +16,13 @@ public class ListValueNormalizerTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Initializes a new instance of the <see cref="ListValueNormalizerTests"/> class.</summary>
public ListValueNormalizerTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
@@ -52,6 +54,8 @@ public class ListValueNormalizerTests : IDisposable
return instance.Id;
}
/// <summary>Verifies the case: template Attribute, old String Form, rewritten To Native.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateAttribute_OldStringForm_RewrittenToNative()
{
@@ -71,6 +75,8 @@ public class ListValueNormalizerTests : IDisposable
Assert.Equal("[10,20]", reloaded.Value);
}
/// <summary>Verifies the case: template Attribute, already Native, is Not Rewritten.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateAttribute_AlreadyNative_IsNotRewritten()
{
@@ -96,6 +102,8 @@ public class ListValueNormalizerTests : IDisposable
Assert.Equal("[10,20]", reloaded.Value);
}
/// <summary>Verifies the case: template Attribute, string List, is Unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateAttribute_StringList_IsUnchanged()
{
@@ -115,6 +123,8 @@ public class ListValueNormalizerTests : IDisposable
Assert.Equal("[\"a\",\"b\"]", reloaded.Value);
}
/// <summary>Verifies the case: template Attribute, malformed, is Skipped, and Sibling Still Normalized.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateAttribute_Malformed_IsSkipped_AndSiblingStillNormalized()
{
@@ -147,6 +157,8 @@ public class ListValueNormalizerTests : IDisposable
Assert.Equal("[5]", good.Value); // normalized
}
/// <summary>Verifies the case: template Attribute, non List Row, is Unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateAttribute_NonListRow_IsUnchanged()
{
@@ -166,6 +178,8 @@ public class ListValueNormalizerTests : IDisposable
Assert.Equal("42", reloaded.Value);
}
/// <summary>Verifies the case: instance Attribute Override, old String Form, rewritten To Native.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceAttributeOverride_OldStringForm_RewrittenToNative()
{
@@ -184,6 +198,8 @@ public class ListValueNormalizerTests : IDisposable
Assert.Equal("[5]", reloaded.OverrideValue);
}
/// <summary>Verifies the case: instance Attribute Override, null Element Type, is Untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceAttributeOverride_NullElementType_IsUntouched()
{
@@ -202,6 +218,8 @@ public class ListValueNormalizerTests : IDisposable
Assert.Equal("42", reloaded.OverrideValue);
}
/// <summary>Verifies the case: normalize Async, is Idempotent, second Run Changes Nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NormalizeAsync_IsIdempotent_SecondRunChangesNothing()
{
@@ -9,7 +9,7 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// Bundle D (#23 M6-T5) integration tests for
/// 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
@@ -27,6 +27,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the tests with the shared SQL Server migration fixture.</summary>
/// <param name="fixture">The fixture that provides the migrated test database.</param>
public AuditLogPartitionMaintenanceTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
@@ -80,6 +82,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
return firstOfThisMonth.AddMonths(1);
}
/// <summary>Verifies that no split occurs and an empty result is returned when the future range already exists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task EnsureLookahead_AlreadyHasFutureRange_NoSplit_ReturnsEmpty()
{
@@ -105,6 +109,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
Assert.Equal(max, maxAfter);
}
/// <summary>Verifies that one missing boundary is split and returned as a single boundary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task EnsureLookahead_NeedsOneMoreBoundary_Splits_Returns1Boundary()
{
@@ -128,6 +134,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
Assert.Equal(expectedAdded, maxAfter);
}
/// <summary>Verifies that three missing boundaries are split and returned as three boundaries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task EnsureLookahead_NeedsThreeBoundaries_Splits_Returns3Boundaries()
{
@@ -153,8 +161,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
}
/// <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
/// The try/catch around the per-month
/// SPLIT call was removed, 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
@@ -163,10 +171,11 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
/// commit boundary N before throwing, and that the next tick can resume from
/// boundary N+1 at-least-once with no holes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
{
// STM: CD-024-SecondSplitThrowsAbortsLoop marker.
// STM: SecondSplitThrowsAbortsLoop marker.
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Baseline max-boundary observed via a clean context.
@@ -180,7 +189,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
// 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
// try/catch, so the exception propagates after the first SPLIT
// has already committed.
var interceptor = new SecondSplitThrowsInterceptor();
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -194,8 +203,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
() => 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.
// before the interceptor's throw: the first split committed, the second threw,
// and the third was never attempted.
Assert.Equal(1, interceptor.SuccessfulSplits);
// And verify the first boundary IS now persisted — the loop aborted but
@@ -217,12 +226,14 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
/// </summary>
private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
{
/// <summary>Gets the number of partition splits that were allowed to run.</summary>
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);
/// <inheritdoc />
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command,
CommandEventData eventData,
@@ -232,6 +243,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
return base.NonQueryExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command,
CommandEventData eventData,
@@ -242,6 +254,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
}
/// <inheritdoc />
public override int NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
@@ -254,6 +267,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
return base.NonQueryExecuted(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
@@ -275,7 +289,7 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
}
// Allow the first SPLIT through; throw on the second so the loop's
// post-CD-019 "let it propagate" behaviour can be asserted.
// "let it propagate" behaviour can be asserted.
if (SuccessfulSplits >= 1)
{
throw new InvalidOperationException(
@@ -284,6 +298,8 @@ public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFix
}
}
/// <summary>Verifies that a boundary that already exists causes no error, making the call idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent()
{
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// Integration tests for <see cref="AuditLogRepository.BackfillSourceNodeAsync"/>
/// (M5.6 T5 — SourceNode sentinel backfill).
/// (SourceNode sentinel backfill).
///
/// <para>
/// These tests exercise the real <see cref="AuditLogRepository"/> against a
@@ -21,6 +21,8 @@ public class BackfillSourceNodeTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared MSSQL migration fixture.</summary>
/// <param name="fixture">The shared MSSQL migration fixture.</param>
public BackfillSourceNodeTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
@@ -83,6 +85,8 @@ VALUES
// 1. SetsNullRowsBeforeThreshold
// ------------------------------------------------------------------
/// <summary>Verifies that the backfill sets rows with a null source node that are before the threshold.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task BackfillSourceNode_SetsNullRowsBeforeThreshold()
{
@@ -118,6 +122,8 @@ VALUES
// 2. LeavesNonNullRowsUntouched
// ------------------------------------------------------------------
/// <summary>Verifies that the backfill leaves rows that already have a source node untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task BackfillSourceNode_LeavesNonNullRowsUntouched()
{
@@ -144,6 +150,8 @@ VALUES
// 3. Idempotent_SecondRunUpdatesZeroRows
// ------------------------------------------------------------------
/// <summary>Verifies that the backfill is idempotent, so a second run updates zero rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task BackfillSourceNode_Idempotent_SecondRunUpdatesZeroRows()
{
@@ -181,6 +189,8 @@ VALUES
// 4. CustomSentinelIsWritten
// ------------------------------------------------------------------
/// <summary>Verifies that a custom sentinel value is written.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task BackfillSourceNode_CustomSentinel_IsWritten()
{
@@ -206,6 +216,8 @@ VALUES
// 5. ArgumentValidation
// ------------------------------------------------------------------
/// <summary>Verifies that an empty sentinel throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackfillSourceNode_EmptySentinel_Throws()
{
@@ -222,6 +234,8 @@ VALUES
() => repo.BackfillSourceNodeAsync("", DateTime.UtcNow, 1000));
}
/// <summary>Verifies that a zero batch size throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackfillSourceNode_ZeroBatchSize_Throws()
{
@@ -18,6 +18,8 @@ public class MediumPersistenceRegressionTests
return db;
}
/// <summary>Verifies that an instance unique name cannot exist at two different sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DL1_UniqueNameCannotExistAtTwoSites()
{
@@ -28,6 +30,8 @@ public class MediumPersistenceRegressionTests
await Assert.ThrowsAsync<InstanceNameConflictException>(() => db.SaveChangesAsync());
}
/// <summary>Verifies that a context with a different data protection key cannot read protected connection configuration.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DL2_ConvertersDoNotShareKeysAcrossProviders()
{
@@ -45,6 +49,8 @@ public class MediumPersistenceRegressionTests
wrongKey.DataConnections.SingleAsync(c => c.Name == "key-isolation"));
}
/// <summary>Verifies that the legacy upgrade protects both configuration columns and is safe to run twice.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DL2_LegacyUpgradeProtectsBothColumnsAndIsIdempotent()
{
@@ -63,6 +69,9 @@ public class MediumPersistenceRegressionTests
Assert.Contains("backup-secret", row.BackupConfiguration);
}
/// <summary>Verifies that data connection configuration is stored encrypted and reads back unchanged.</summary>
/// <param name="backup">True to exercise the backup configuration column, false for the primary.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -83,6 +92,9 @@ public class MediumPersistenceRegressionTests
Assert.Equal(secret, backup ? loaded.BackupConfiguration : loaded.PrimaryConfiguration);
}
/// <summary>Verifies that a context without a data protection provider refuses to write connection configuration.</summary>
/// <param name="backup">True to exercise the backup configuration column, false for the primary.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -17,6 +17,8 @@ public class NativeAlarmSourceSchemaTests
return ctx;
}
/// <summary>Verifies that a template native alarm source persists through its template.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateNativeAlarmSource_PersistsViaTemplate()
{
@@ -30,6 +32,8 @@ public class NativeAlarmSourceSchemaTests
Assert.Single(ctx.TemplateNativeAlarmSources);
}
/// <summary>Verifies that the instance native alarm source override DbSet is mapped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InstanceNativeAlarmSourceOverride_DbSetIsMapped()
{
@@ -7,8 +7,8 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Query-shape coverage for the Notification Outbox KPI snapshots (arch-review 04,
/// Task 20). Each snapshot must be a single-query conditional aggregation with a
/// Query-shape coverage for the Notification Outbox KPI snapshots (arch-review 04).
/// Each snapshot must be a single-query conditional aggregation with a
/// bounded oldest-pending — the old shape issued 5-7 sequential COUNT round trips
/// and materialized the whole live queue to find the oldest row.
/// </summary>
@@ -25,6 +25,8 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests
SourceNode = sourceNode,
};
/// <summary>Verifies that all three KPI snapshots issue at most two queries and bound the oldest-pending lookup.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AllThreeKpiSnapshots_IssueAtMostTwoQueries_AndBoundTheOldestLookup()
{
@@ -50,7 +52,7 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests
$"ComputeKpisAsync issued {counter.Count} queries against Notifications; expected <= 2");
// The oldest lookup must be bounded (LIMIT/TOP), never a full non-terminal SELECT.
Assert.Contains(counter.Commands, sql => sql.Contains("LIMIT", StringComparison.OrdinalIgnoreCase));
// WP2.2: the aggregation must be PREDICATE-RESTRICTED, not an unrestricted
// The aggregation must be PREDICATE-RESTRICTED, not an unrestricted
// scan. Every KPI here concerns the live queue, the parked backlog or the
// last delivery interval; historical Delivered/Discarded rows — which are
// the overwhelming bulk of a retained table — contribute to none of them.
@@ -90,10 +92,13 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests
{
private readonly List<string> _commands = new();
/// <summary>The text of every reader command recorded so far.</summary>
public IReadOnlyList<string> Commands => _commands;
/// <summary>The number of reader commands recorded so far.</summary>
public int Count => _commands.Count;
/// <summary>Clears all recorded commands.</summary>
public void Reset() => _commands.Clear();
private void Record(DbCommand command)
@@ -104,6 +109,7 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests
}
}
/// <inheritdoc />
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
@@ -111,6 +117,7 @@ public class NotificationOutboxRepositoryKpiQueryShapeTests
return base.ReaderExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
// Coverage for per-node KPI aggregation in the Notification Outbox repository
// (T6: M5.2 per-node stuck-count KPIs).
// (per-node stuck-count KPIs).
public class NotificationOutboxRepositoryPerNodeKpiTests
{
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
@@ -27,6 +27,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests
};
}
/// <summary>Verifies that per-node KPIs are aggregated separately for each source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputePerNodeKpisAsync_AggregatesMetricsPerNode()
{
@@ -70,6 +72,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests
Assert.NotNull(b.OldestPendingAge);
}
/// <summary>Verifies that notifications with a null source node are excluded from the per-node KPIs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputePerNodeKpisAsync_ExcludesNullSourceNode()
{
@@ -88,6 +92,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests
Assert.Empty(result);
}
/// <summary>Verifies that having no notifications yields an empty per-node KPI result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputePerNodeKpisAsync_ReturnsEmpty_WhenNoNotifications()
{
@@ -98,6 +104,8 @@ public class NotificationOutboxRepositoryPerNodeKpiTests
Assert.Empty(result);
}
/// <summary>Verifies that the oldest pending age reflects the older row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputePerNodeKpisAsync_OldestPendingAge_ReflectsOlderRow()
{
@@ -7,7 +7,7 @@ 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).
// (per-site KPIs).
public class NotificationOutboxRepositoryPerSiteKpiTests
{
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
@@ -27,6 +27,8 @@ public class NotificationOutboxRepositoryPerSiteKpiTests
};
}
/// <summary>Verifies that per-site KPIs are aggregated separately for each site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite()
{
@@ -79,6 +81,8 @@ public class NotificationOutboxRepositoryPerSiteKpiTests
$"expected OldestPendingAge < 95m, got {c.OldestPendingAge}");
}
/// <summary>Verifies that having no notifications yields an empty per-site KPI result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications()
{
@@ -19,12 +19,14 @@ public class PendingDeploymentRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly DeploymentManagerRepository _repository;
/// <summary>Initializes the test class with an in-memory context and repository.</summary>
public PendingDeploymentRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new DeploymentManagerRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
@@ -58,6 +60,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
createdAtUtc: now, expiresAtUtc: now.AddMinutes(10));
}
/// <summary>Verifies that a second pending deployment for the same instance supersedes the prior row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddPendingDeployment_SecondDeployForSameInstance_SupersedesPriorRow()
{
@@ -78,6 +82,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Equal(instanceId, current.InstanceId);
}
/// <summary>Verifies that pending deployments for different instances coexist.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddPendingDeployment_DifferentInstances_Coexist()
{
@@ -96,6 +102,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Equal(instance2, b!.InstanceId);
}
/// <summary>Verifies that purging expired pending deployments removes only the expired rows and returns the count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeExpiredPendingDeployments_RemovesOnlyExpired_ReturnsCount()
{
@@ -123,6 +131,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1"));
}
/// <summary>Verifies that purging expired pending deployments returns zero when none are expired.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeExpiredPendingDeployments_NoneExpired_ReturnsZero()
{
@@ -138,6 +148,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.NotNull(await _repository.GetPendingDeploymentByIdAsync("live-1"));
}
/// <summary>Verifies that deleting a pending deployment by id removes the row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeletePendingDeploymentById_RemovesRow()
{
@@ -151,6 +163,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep1"));
}
/// <summary>Verifies that deleting a pending deployment with an unknown id does not throw.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeletePendingDeploymentById_UnknownId_NoThrow()
{
@@ -159,6 +173,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("does-not-exist"));
}
/// <summary>Verifies that getting a pending deployment by instance id returns the row when present.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPendingDeploymentByInstanceId_ReturnsRow_WhenPresent()
{
@@ -178,6 +194,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Equal("{\"v\":9}", row.ConfigurationJson);
}
/// <summary>Verifies that getting a pending deployment by instance id returns null when absent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPendingDeploymentByInstanceId_ReturnsNull_WhenAbsent()
{
@@ -189,6 +207,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Null(row);
}
/// <summary>Verifies that staging is not blocked by an expired row with a different deployment id, which is removed and replaced by the live row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_ExpiredRowDifferentDeploymentId_DoesNotBlock_RemovesExpiredAndStagesLiveRow()
{
@@ -219,6 +239,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.True(row.ExpiresAtUtc > now); // the new row is live
}
/// <summary>Verifies that staging over an expired row with the same deployment id causes no unique-key collision and stages the new row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_NoUniqueCollision_StagesNew()
{
@@ -245,6 +267,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.True(row.ExpiresAtUtc > now);
}
/// <summary>Verifies that a live row blocks staging and is left untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_LiveRow_Blocks_LeavesUntouched()
{
@@ -269,6 +293,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-new"));
}
/// <summary>Verifies that getting a pending deployment by instance id with an explicit current time returns the live, unexpired row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetPendingDeploymentByInstanceId_WithNowUtc_ReturnsLiveRow_NotExpired()
{
@@ -294,6 +320,8 @@ public class PendingDeploymentRepositoryTests : IDisposable
Assert.Equal("dep-getlive", live!.DeploymentId);
}
/// <summary>Verifies that adding a pending deployment supersedes all prior rows for the same instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddPendingDeployment_MultiplePriorRowsSameInstance_AllSuperseded()
{
@@ -22,12 +22,14 @@ public class ReconcileRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly DeploymentManagerRepository _repository;
/// <summary>Initializes the test with an in-memory database context and repository.</summary>
public ReconcileRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new DeploymentManagerRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
@@ -69,6 +71,8 @@ public class ReconcileRepositoryTests : IDisposable
// --- GetExpectedDeploymentsForSiteAsync ---
/// <summary>Verifies that only deployed instances belonging to the site are returned as expected deployments.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExpectedDeployments_ReturnsOnlyDeployedInstancesForSite()
{
@@ -115,6 +119,8 @@ public class ReconcileRepositoryTests : IDisposable
Assert.False(a2.IsEnabled);
}
/// <summary>Verifies that no expected deployments are returned when the site has no deployed instances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExpectedDeployments_NoDeployedInstances_ReturnsEmpty()
{
@@ -129,6 +135,8 @@ public class ReconcileRepositoryTests : IDisposable
// --- StagePendingIfAbsentAsync ---
/// <summary>Verifies that staging a pending deployment with no prior row stages it and returns true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_NoPriorRow_StagesAndReturnsTrue()
{
@@ -157,6 +165,8 @@ public class ReconcileRepositoryTests : IDisposable
Assert.Equal("rev-new", row.RevisionHash);
}
/// <summary>Verifies that staging a pending deployment when a row already exists returns false and does not overwrite it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StagePendingIfAbsent_PriorRowExists_ReturnsFalseAndDoesNotOverwrite()
{
@@ -12,7 +12,7 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// Bundle D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses
/// Integration tests for <see cref="AuditLogRepository"/>. Uses
/// the same <see cref="MsSqlMigrationFixture"/> as the Bundle C migration tests so
/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a
/// real partitioned schema. Tests scope all queries by a per-test
@@ -23,11 +23,15 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Creates the test class with the shared migrated SQL Server fixture.</summary>
/// <param name="fixture">The shared migration fixture.</param>
public AuditLogRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that inserting a fresh event writes one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow()
{
@@ -51,6 +55,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(evt.EventId, loaded[0].EventId);
}
/// <summary>Verifies that inserting an event persists its source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_PersistsSourceNode()
{
@@ -75,6 +81,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("central-a", loaded[0].SourceNode);
}
/// <summary>Verifies that inserting an event with no source node persists a null source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_PersistsNullSourceNode()
{
@@ -101,6 +109,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(loaded[0].SourceNode);
}
/// <summary>Verifies that inserting a duplicate event id is a no-op that neither throws nor duplicates the row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate()
{
@@ -115,7 +125,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(first);
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
// C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild
// ErrorMessage rides in DetailsJson on the canonical record, so rebuild
// a sibling row carrying the same EventId via the factory (rather than a top-level `with`).
var second = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
@@ -133,11 +143,13 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
.ToListAsync();
Assert.Single(loaded);
// C5 (Task 2.5): ErrorMessage rides in DetailsJson now — decode it to assert
// ErrorMessage rides in DetailsJson — decode it to assert
// first-write-wins kept the original payload.
Assert.Equal("first", AuditDetailsCodec.Deserialize(loaded[0].DetailsJson).ErrorMessage);
}
/// <summary>Verifies that a query returns rows in descending occurred-time order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_ReturnsRowsInOccurredDescOrder()
{
@@ -161,6 +173,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc);
}
/// <summary>Verifies that a query can filter by channel.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByChannel()
{
@@ -185,6 +199,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel));
}
/// <summary>Verifies that filtering by several channels returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion()
{
@@ -212,6 +228,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound);
}
/// <summary>Verifies that filtering by several statuses returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion()
{
@@ -238,6 +256,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered);
}
/// <summary>Verifies that filtering by several source site ids returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion()
{
@@ -263,6 +283,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC);
}
/// <summary>Verifies that an empty channel list does not constrain the query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_EmptyChannelList_DoesNotConstrain()
{
@@ -286,6 +308,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(2, rows.Count);
}
/// <summary>Verifies that a query can filter by source site id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterBySourceSiteId()
{
@@ -310,12 +334,14 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ──────────────────────────────────────────────────────────────────────
// Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
// SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
// Node multi-select contract — non-empty list → SQL IN (…); NULL
// SourceNode rows are excluded when the filter is set; the distinct
// enumeration omits nulls and orders ascending.
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that filtering by source node returns the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterBySourceNode_ReturnsMatchingRows()
{
@@ -340,6 +366,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("central-a", rows[0].SourceNode);
}
/// <summary>Verifies that filtering by several source nodes returns their union.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByMultipleSourceNodes_ReturnsUnion()
{
@@ -364,6 +392,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" }));
}
/// <summary>Verifies that the distinct source nodes are returned without nulls and in ascending order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetDistinctSourceNodesAsync_ReturnsDistinctNonNullValues_Ordered()
{
@@ -394,6 +424,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes);
}
/// <summary>Verifies that filtering by execution id returns the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
{
@@ -422,6 +454,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId));
}
/// <summary>Verifies that filtering by parent execution id returns the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByParentExecutionId_ReturnsMatchingRows()
{
@@ -451,6 +485,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId));
}
/// <summary>Verifies that a query can filter by time range.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByTimeRange()
{
@@ -476,6 +512,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc);
}
/// <summary>Verifies that the next keyset page starts after the cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_Keyset_NextPageStartsAfterCursor()
{
@@ -525,6 +563,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc);
}
/// <summary>Verifies that concurrent duplicate inserts produce exactly one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow()
{
@@ -561,9 +601,11 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// WP2.2: set-based InsertManyIfNotExistsAsync
// Set-based InsertManyIfNotExistsAsync
// ------------------------------------------------------------------------
/// <summary>Verifies that a batch insert writes every distinct event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_WritesEveryDistinctEvent()
{
@@ -593,6 +635,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
rows.Select(r => r.EventId).OrderBy(g => g).ToArray());
}
/// <summary>Verifies that duplicate event ids within one batch collapse to a single row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsWithinOnePacket_CollapseToOneRow()
{
@@ -638,6 +682,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(AuditDetailsCodec.Deserialize(stored.DetailsJson).ErrorMessage);
}
/// <summary>Verifies that duplicate event ids across batches are inserted idempotently.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsAcrossPackets_AreIdempotent()
{
@@ -675,6 +721,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(6, rows.Count);
}
/// <summary>Verifies that a batch larger than the SQL parameter ceiling is inserted in chunks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_ChunksBeyondTheParameterCeiling()
{
@@ -718,6 +766,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// server raise 2628/8152. Reject-everywhere is the contract; this test pins
/// both halves of it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_TargetLongerThanColumn_IsRejected_NotTruncated()
{
@@ -736,7 +785,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var batchRepo = new AuditLogRepository(batchContext);
// 2628 = "String or binary data would be truncated in table …" (SQL 2019+),
// 8152 = its pre-2019 predecessor. Either proves the server saw the full value.
// 8152 = its predecessor on SQL Server versions before 2019. Either proves the server saw the full value.
var batchEx = await Assert.ThrowsAsync<SqlException>(
() => batchRepo.InsertManyIfNotExistsAsync(new[] { evt }));
Assert.Contains(batchEx.Number, new[] { 2628, 8152 });
@@ -761,6 +810,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// The companion to the rejection test: a value that FITS must still round-trip
/// through the set-based path byte for byte, at the exact column boundary.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_TargetAtColumnLimit_RoundTripsIntact()
{
@@ -786,6 +836,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(boundaryTarget, loaded[0].Target);
}
/// <summary>Verifies that inserting an empty batch is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertManyIfNotExistsAsync_EmptyBatch_IsANoOp()
{
@@ -797,6 +849,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, await repo.InsertManyIfNotExistsAsync(Array.Empty<AuditEvent>()));
}
/// <summary>Verifies that keyset paging breaks ties on event id when rows share an occurred time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId()
{
@@ -855,12 +909,12 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests
// SwitchOutPartitionAsync drop-and-rebuild integration tests
// ------------------------------------------------------------------------
//
// The partition-switch path replaces M1's NotSupportedException stub with
// The partition-switch path replaces the earlier NotSupportedException stub with
// the production CREATE-staging → SWITCH PARTITION → DROP-staging batch
// documented in alog.md §4. WP2.2 removed the index drop/rebuild that used to
// documented in alog.md §4. The design removed the index drop/rebuild that used to
// bracket it: uniqueness rides the partition-ALIGNED clustered PK
// (EventId, OccurredAtUtc), so SWITCH has nothing to object to. These tests
// verify the side effects an outsider can observe:
@@ -871,6 +925,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// after a switch (the aligned key really does enforce it)
// * a thrown SqlException leaves the table intact with no orphaned staging
/// <summary>Verifies that switching out an old partition removes its rows and keeps newer partitions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept()
{
@@ -906,6 +962,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Contains(remaining, e => e.EventId == marEvt.EventId);
}
/// <summary>Verifies that a partition switch leaves no non-aligned unique index behind.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_LeavesNoNonAlignedUniqueIndex_AfterSwitch()
{
@@ -918,7 +976,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// the fixture's MSSQL database) don't tread on each other.
await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
// WP2.2: the switch must NOT recreate UX_AuditLog_EventId. Re-adding it
// The switch must NOT recreate UX_AuditLog_EventId. Re-adding it
// would reinstate the offline drop/rebuild the purge was freed from and
// block the next SWITCH until something dropped it again.
await using var verifyContext = CreateContext();
@@ -937,6 +995,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(1, pkExists);
}
/// <summary>Verifies that first-write-wins insertion is still enforced after a partition switch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_InsertIfNotExistsAsync_StillEnforcesFirstWriteWins_AfterSwitch()
{
@@ -959,7 +1019,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// column instead of a dedicated unique index; if the switch had disturbed
// that key, this insert would silently produce a duplicate row and the
// count assertion below would catch it.
// C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
// Rebuild a sibling row with the same EventId via the factory
// (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
var dup = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
@@ -978,11 +1038,13 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Single(rows);
Assert.Equal(preExisting.EventId, rows[0].EventId);
// First-write-wins: the original ErrorMessage (null) survives. C5 (Task 2.5):
// First-write-wins: the original ErrorMessage (null) survives.
// ErrorMessage rides in DetailsJson — decode it to assert.
Assert.Null(AuditDetailsCodec.Deserialize(rows[0].DetailsJson).ErrorMessage);
}
/// <summary>Verifies that a partial partition-switch failure raises an exception and leaves no orphaned staging table.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task SwitchOutPartitionAsync_PartialFailure_RaisesException_LeavesNoOrphanedStaging()
{
@@ -1045,8 +1107,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
"SELECT COUNT(*) FROM sys.tables WHERE name LIKE 'AuditLog\\_Staging\\_%' ESCAPE '\\';");
Assert.Equal(0, orphanedStaging);
// And it must NOT have (re)created the non-aligned unique index: WP2.2
// deleted that rebuild entirely, so a failed switch cannot reintroduce
// And it must NOT have (re)created the non-aligned unique index: the
// design deleted that rebuild entirely, so a failed switch cannot reintroduce
// the object that blocks the NEXT switch.
var indexExists = await ScalarAsync<int>(
verifyContext,
@@ -1056,9 +1118,11 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync
// GetPartitionBoundariesOlderThanAsync
// ------------------------------------------------------------------------
/// <summary>Verifies that only partition boundaries whose newest row is older than the threshold are returned.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetPartitionBoundariesOlderThanAsync_ReturnsBoundaries_WithMaxOccurredOlderThanThreshold()
{
@@ -1087,7 +1151,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
// GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
// ------------------------------------------------------------------------
//
// The dashboard's "Audit volume" tile reads TotalEventsLastHour and the
@@ -1102,6 +1166,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// narrow band centred on that anchor — no other test in this class seeds
// there, so the global count equals the seeded count for that band.
/// <summary>Verifies that the KPI snapshot reports correct totals and error counts for rows of mixed status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors()
{
@@ -1141,6 +1207,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(nowUtc, snapshot.AsOfUtc);
}
/// <summary>Verifies that the KPI snapshot of an empty window reports zero totals.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals()
{
@@ -1163,7 +1231,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
// ------------------------------------------------------------------------
// Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync
// Audit Log ParentExecutionId: GetExecutionTreeAsync
// ------------------------------------------------------------------------
//
// GetExecutionTreeAsync walks UP from any node to the chain root, then walks
@@ -1175,6 +1243,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// * pathological cyclic data is bounded by the MAXRECURSION guard and
// surfaces a SqlException rather than hanging
/// <summary>Verifies that the execution tree resolves a multi-level parent chain.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetExecutionTree_MultiLevelChain()
{
@@ -1267,6 +1337,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that the execution tree edge scan is bounded to the root window.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ExecutionTree_Bounds_EdgeScan_To_Root_Window()
{
@@ -1310,6 +1382,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(2, tree.Count);
}
/// <summary>Verifies that the execution tree includes a stub node for a parent with no audit rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetExecutionTree_StubParentNode()
{
@@ -1351,6 +1425,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(stubParentExec, child.ParentExecutionId);
}
/// <summary>Verifies that the execution tree walk respects the maximum recursion depth.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetExecutionTree_RespectsMaxRecursion()
{
@@ -1413,7 +1489,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
private static string NewSiteId() =>
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// Build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
// factory; the repository's transitional shim decomposes it into the 24-column
// AuditLogRow on INSERT and recomposes the canonical record on QUERY.
private static AuditEvent NewEvent(
@@ -17,11 +17,15 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary>
/// <param name="fixture">The shared SQL Server migration fixture.</param>
public AuditLogRepositoryTraceEventsTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
private ScadaBridgeDbContext CreateContext() => new(
new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>Verifies that scoped tree and replay reads exclude other sites' rows and retain central rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ScopedTreeAndReplay_ExcludeOtherSite_AndRetainCentralRows()
{
@@ -40,6 +44,10 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi
Assert.DoesNotContain(replay, row => row.DetailsJson?.Contains("sensitive-sql-values") == true);
}
/// <summary>Verifies that replay bounds the number of rows and their UTF-8 byte size while keeping the root row.</summary>
/// <param name="count">The number of audit rows to seed.</param>
/// <param name="payloadLength">The length of each row's details payload.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableTheory]
[InlineData(600, 0)]
[InlineData(200, 10000)]
@@ -64,6 +72,8 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi
Assert.True(batch.Sum(e => System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(e).Length) <= 1024 * 1024);
}
/// <summary>Verifies that the tree read returns the inbound row plus chain trace rows in order from any entry point.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ReturnsInboundPlusChainTraceRows_Ordered_FromAnyEntry()
{
@@ -118,10 +128,11 @@ public class AuditLogRepositoryTraceEventsTests : IClassFixture<MsSqlMigrationFi
}
/// <summary>
/// Outbound-calls plan (2026-09-06, D6): replay returns the chain's ScriptTrace, ApiOutbound,
/// Replay returns the chain's ScriptTrace, ApiOutbound,
/// DbOutbound and Notification rows plus the InboundRequest row, in (OccurredAtUtc, EventId)
/// order; SecuredWrite, Cluster and InboundAuthFailure rows in the same tree stay excluded.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task IncludesOutboundApiDbAndNotificationRows_ExcludesOtherChannels()
{
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
public class AuditPurgeBudgetTests
{
/// <summary>Verifies that a backlogged channel purge stops at the row budget, including a partial final batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackloggedChannel_StopsAtBudgetIncludingPartialFinalBatch()
{
@@ -24,32 +26,53 @@ public class AuditPurgeBudgetTests
private sealed class BackloggedConnection : DbConnection
{
/// <summary>Gets the batch sizes requested by the purge, in order.</summary>
public List<int> RequestedBatches { get; } = new();
private ConnectionState _state;
/// <inheritdoc />
[AllowNull] public override string ConnectionString { get; set; } = "Server=localhost;Database=test";
/// <inheritdoc />
public override string Database => "test";
/// <inheritdoc />
public override string DataSource => "localhost";
/// <inheritdoc />
public override string ServerVersion => "16.0";
/// <inheritdoc />
public override ConnectionState State => _state;
/// <inheritdoc />
public override void Open() => _state = ConnectionState.Open;
/// <inheritdoc />
public override void Close() => _state = ConnectionState.Closed;
/// <inheritdoc />
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException();
/// <inheritdoc />
protected override DbCommand CreateDbCommand() => new BackloggedCommand(this);
}
private sealed class BackloggedCommand(BackloggedConnection owner) : DbCommand
{
private readonly SqlCommand _parameters = new();
/// <inheritdoc />
[AllowNull] public override string CommandText { get; set; } = "";
/// <inheritdoc />
public override int CommandTimeout { get; set; }
/// <inheritdoc />
public override CommandType CommandType { get; set; }
/// <inheritdoc />
public override bool DesignTimeVisible { get; set; }
/// <inheritdoc />
public override UpdateRowSource UpdatedRowSource { get; set; }
/// <inheritdoc />
protected override DbConnection? DbConnection { get; set; }
/// <inheritdoc />
protected override DbTransaction? DbTransaction { get; set; }
/// <inheritdoc />
protected override DbParameterCollection DbParameterCollection => _parameters.Parameters;
/// <inheritdoc />
protected override DbParameter CreateDbParameter() => new SqlParameter();
/// <inheritdoc />
public override int ExecuteNonQuery()
{
// A server with more eligible rows than any tick is allowed to delete.
@@ -60,10 +83,15 @@ public class AuditPurgeBudgetTests
owner.RequestedBatches.Add(requested);
return requested;
}
/// <inheritdoc />
public override object? ExecuteScalar() => throw new NotSupportedException();
/// <inheritdoc />
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException();
/// <inheritdoc />
public override void Cancel() { }
/// <inheritdoc />
public override void Prepare() { }
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) _parameters.Dispose();
@@ -15,6 +15,8 @@ public class BundleUnlockAttemptRepositoryTests
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
/// <summary>Verifies that counting attempts includes only the given bucket within the window.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CountAttemptsSinceAsync_CountsOnlyTheGivenBucketInsideTheWindow()
{
@@ -32,6 +34,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(2, count);
}
/// <summary>Verifies that counting attempts is inclusive at the window floor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CountAttemptsSinceAsync_IsInclusiveAtTheWindowFloor()
{
@@ -46,6 +50,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1)));
}
/// <summary>Verifies that counting attempts for an unknown bucket returns zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CountAttemptsSinceAsync_UnknownBucket_ReturnsZero()
{
@@ -55,6 +61,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(0, await repo.CountAttemptsSinceAsync("<unattributed>", Base.AddHours(-1)));
}
/// <summary>Verifies that pruning deletes only aged rows and respects the batch bound.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PruneAttemptsOlderThanAsync_DeletesOnlyAgedRows_AndIsBounded()
{
@@ -78,6 +86,8 @@ public class BundleUnlockAttemptRepositoryTests
Assert.Equal(0, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3));
}
/// <summary>Verifies that pruning rejects a non-positive batch size.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PruneAttemptsOlderThanAsync_RejectsANonPositiveBatchSize()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// M6 (K2) coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite
/// Coverage for <see cref="KpiHistoryRepository"/> over the in-memory SQLite
/// harness. Exercises the bulk write, the single-series query (including the
/// null-ScopeKey Global match), and the retention purge.
/// </summary>
@@ -31,6 +31,8 @@ public class KpiHistoryRepositoryTests
CapturedAtUtc = capturedAtUtc,
};
/// <summary>Verifies that the raw series query returns only the matching series in ascending time order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetRawSeriesAsync_ReturnsOnlyMatchingSeries_InAscendingTimeOrder()
{
@@ -64,6 +66,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(7, series[1].Value);
}
/// <summary>Verifies that a site-scoped raw series query matches only rows with that scope key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetRawSeriesAsync_SiteScopedKey_MatchesOnlyThatKey()
{
@@ -87,6 +91,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(5, series[0].Value);
}
/// <summary>Verifies that the raw series query honors the time window bounds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetRawSeriesAsync_HonorsTimeWindowBounds()
{
@@ -109,6 +115,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 1d, 2d }, series.Select(p => p.Value).ToArray());
}
/// <summary>Verifies that recording an empty batch is a no-op and querying an empty table returns an empty series.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecordSamplesAsync_EmptyBatch_IsNoOp_AndGetRawSeriesAsync_EmptyTable_ReturnsEmpty()
{
@@ -127,6 +135,8 @@ public class KpiHistoryRepositoryTests
Assert.Empty(series);
}
/// <summary>Verifies that the purge deletes only rows older than the cutoff and returns the deleted count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeOlderThanAsync_DeletesOnlyRowsOlderThanCutoff_AndReturnsCount()
{
@@ -158,6 +168,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
/// <summary>Verifies that a multi-window purge issues more than one delete and returns the total deleted count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeOlderThanAsync_MultiWindow_IssuesMoreThanOneDelete_AndReturnsTotal()
{
@@ -196,8 +208,10 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 4d, 5d }, remaining.Select(p => p.Value).ToArray());
}
// ---- Hourly rollup fold / query / purge (plan #22 T3) ------------------------------
// ---- Hourly rollup fold / query / purge ------------------------------
/// <summary>Verifies that a gauge metric folds to its last value per hour.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_GaugeMetric_FoldsToLastValuePerHour()
{
@@ -224,6 +238,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(9, row.Value); // last-value (max CapturedAtUtc)
}
/// <summary>Verifies that a rate metric folds to its sum per hour along with the minimum, maximum and count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_RateMetric_FoldsToSumPerHour_WithMinMaxCount()
{
@@ -249,6 +265,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(3, rollup.SampleCount);
}
/// <summary>Verifies that the fold splits global-scope samples into separate hour buckets.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_SplitsIntoSeparateHourBuckets_ForGlobalScope()
{
@@ -277,6 +295,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(2, series[1].Value); // hour 1 last-value
}
/// <summary>Verifies that the fold excludes an incomplete hour at the exclusive upper bound.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_ExcludesIncompleteHour_AtExclusiveUpperBound()
{
@@ -303,6 +323,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(3, row.Value);
}
/// <summary>Verifies that repeated folds over the same window are idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_IsIdempotent_AcrossRepeatedRunsOverSameWindow()
{
@@ -325,10 +347,12 @@ public class KpiHistoryRepositoryTests
Assert.Equal(2, row.SampleCount);
}
/// <summary>Verifies that the fold preloads existing rollups instead of probing once per series and hour.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollupsAsync_PreloadsExistingRollups_WithoutAPerSeriesHourProbe()
{
// WP2.2: the fold used to issue one FirstOrDefaultAsync existence probe per
// The fold used to issue one FirstOrDefaultAsync existence probe per
// (series, hour) group before writing anything — an N+1 that scaled with
// the metric catalogue times the lookback window. It now preloads the whole
// window in a single query and resolves each group from a dictionary.
@@ -376,6 +400,8 @@ public class KpiHistoryRepositoryTests
Assert.All(rollups, r => Assert.Equal(1, r.SampleCount));
}
/// <summary>Verifies that the hourly series query returns rows in ascending order and distinguishes a null scope key from a site scope key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetHourlySeriesAsync_ReturnsAscending_AndHonorsNullVsSiteScopeKey()
{
@@ -409,6 +435,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(42, Assert.Single(site).Value);
}
/// <summary>Verifies that the rollup purge deletes only rollups older than the cutoff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PurgeRollupsOlderThanAsync_DeletesOnlyRollupsOlderThanCutoff()
{
@@ -437,6 +465,8 @@ public class KpiHistoryRepositoryTests
Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray());
}
/// <summary>Verifies that the fold does not leave the sample entities tracked by the change tracker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FoldHourlyRollups_DoesNotTrack_KpiSampleEntities()
{
@@ -457,6 +487,8 @@ public class KpiHistoryRepositoryTests
Assert.Empty(ctx.ChangeTracker.Entries<KpiSample>());
}
/// <summary>Verifies that the latest rollup hour is null when no rollups exist.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetLatestRollupHour_ReturnsNull_WhenNoRollupsExist()
{
@@ -465,6 +497,8 @@ public class KpiHistoryRepositoryTests
Assert.Null(await repo.GetLatestRollupHourAsync());
}
/// <summary>Verifies that the latest rollup hour is the newest hour start across all series.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetLatestRollupHour_ReturnsNewestHourStart_AcrossAllSeries()
{
@@ -480,8 +514,10 @@ public class KpiHistoryRepositoryTests
Assert.Equal(Base.AddHours(3), await repo.GetLatestRollupHourAsync());
}
// ---- Task 9: failover fold-race failure grain is the pass, not the row (R5) ----
// ---- Failover fold-race failure grain is the pass, not the row (R5) ----
/// <summary>Verifies that a fold that loses a failover upsert race does not throw and the next fold self-heals.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Fold_LosingFailoverUpsertRace_DoesNotThrow_AndNextFoldSelfHeals()
{
@@ -556,12 +592,16 @@ public class KpiHistoryRepositoryTests
private readonly Func<KpiRollupHourly> _rowFactory;
private bool _fired;
/// <summary>Initializes the interceptor.</summary>
/// <param name="connection">The shared database connection used for the conflicting side write.</param>
/// <param name="rowFactory">Creates the conflicting rollup row to insert.</param>
public InsertConflictingRollupOnFirstSave(DbConnection connection, Func<KpiRollupHourly> rowFactory)
{
_connection = connection;
_rowFactory = rowFactory;
}
/// <inheritdoc />
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
@@ -584,13 +624,6 @@ public class KpiHistoryRepositoryTests
}
}
/// <summary>
/// EF command interceptor that counts the DELETE statements actually issued
/// to the store, so a test can prove the purge is sliced into multiple
/// windowed DELETEs rather than one mega-statement. Threads both the sync and
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
/// async path).
/// </summary>
/// <summary>
/// Counts reader commands that SELECT from <c>KpiRollupHourly</c>, so a test
/// can prove the hourly fold resolves existing rows from a single preload
@@ -598,8 +631,10 @@ public class KpiHistoryRepositoryTests
/// </summary>
private sealed class RollupSelectCountingInterceptor : DbCommandInterceptor
{
/// <summary>Number of reader commands that selected from the rollup table.</summary>
public int RollupSelectCount { get; private set; }
/// <summary>Resets the select counter to zero.</summary>
public void Reset() => RollupSelectCount = 0;
private void CountIfRollupSelect(DbCommand command)
@@ -611,6 +646,7 @@ public class KpiHistoryRepositoryTests
}
}
/// <inheritdoc />
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
@@ -618,6 +654,7 @@ public class KpiHistoryRepositoryTests
return base.ReaderExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
@@ -627,10 +664,19 @@ public class KpiHistoryRepositoryTests
}
}
/// <summary>
/// EF command interceptor that counts the DELETE statements actually issued
/// to the store, so a test can prove the purge is sliced into multiple
/// windowed DELETEs rather than one mega-statement. Threads both the sync and
/// async non-query entry points (<c>ExecuteDeleteAsync</c> routes through the
/// async path).
/// </summary>
private sealed class DeleteCountingInterceptor : DbCommandInterceptor
{
/// <summary>Number of DELETE statements issued so far.</summary>
public int DeleteCount { get; private set; }
/// <summary>Resets the delete counter to zero.</summary>
public void Reset() => DeleteCount = 0;
private void CountIfDelete(DbCommand command)
@@ -641,6 +687,7 @@ public class KpiHistoryRepositoryTests
}
}
/// <inheritdoc />
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
{
@@ -648,6 +695,7 @@ public class KpiHistoryRepositoryTests
return base.NonQueryExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
@@ -10,7 +10,7 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// CD-015 race-fix integration tests for
/// 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,
@@ -21,11 +21,15 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes a new instance of the <c>NotificationOutboxRepositoryIntegrationTests</c> class.</summary>
/// <param name="fixture">The shared database fixture.</param>
public NotificationOutboxRepositoryIntegrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that inserting a fresh id inserts the row and returns true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_FreshId_InsertsAndReturnsTrue()
{
@@ -45,6 +49,8 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
Assert.Equal("Subject", loaded!.Subject);
}
/// <summary>Verifies that inserting a duplicate id returns false and leaves the existing row untouched.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task InsertIfNotExistsAsync_DuplicateId_ReturnsFalseAndLeavesExistingRow()
{
@@ -70,12 +76,14 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
Assert.Equal("Original", loaded!.Subject);
}
/// <summary>Verifies that concurrent inserts of the same id produce exactly one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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
// 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
@@ -102,6 +110,8 @@ public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlM
Assert.Equal(1, count);
}
/// <summary>Verifies that filtering by notification id returns exactly that row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_NotificationIdFilter_ReturnsExactlyThatRow()
{
@@ -25,11 +25,15 @@ public class PendingDeploymentRepositoryIntegrationTests : IClassFixture<MsSqlMi
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the test class with the shared SQL Server migration fixture.</summary>
/// <param name="fixture">The shared SQL Server migration fixture.</param>
public PendingDeploymentRepositoryIntegrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that staging over an expired row with the same deployment id re-stages it without a unique violation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task StagePendingIfAbsent_ExpiredRowSameDeploymentId_ReStages_NoUniqueViolation()
{
@@ -74,6 +78,8 @@ public class PendingDeploymentRepositoryIntegrationTests : IClassFixture<MsSqlMi
}
}
/// <summary>Verifies on a real SQL Server that a new deploy with a different id supersedes the prior pending row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task AddPendingDeployment_NewDeployDifferentId_SupersedesPriorRow_OnRealSqlServer()
{
@@ -12,8 +12,8 @@ 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
/// Integration tests for <see cref="SiteCallAuditRepository"/>.
/// Uses the same <see cref="MsSqlMigrationFixture"/> as the 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.
@@ -22,11 +22,15 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that an existing operation cannot be advanced by a packet from another site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task CM9_ExistingOperationCannotBeAdvancedByAnotherSite()
{
@@ -46,6 +50,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, stored.RetryCount);
}
/// <summary>Verifies that upserting a fresh id inserts exactly one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_FreshId_InsertsOneRow()
{
@@ -68,6 +74,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, loaded[0].RetryCount);
}
/// <summary>Verifies that upserting an advanced status updates the existing row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_AdvancedStatus_UpdatesRow()
{
@@ -90,6 +98,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("transient 503", loaded.LastError);
}
/// <summary>Verifies that upserting an older status is a no-op and leaves the row unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged()
{
@@ -115,6 +125,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc);
}
/// <summary>Verifies that an upsert rejected by the monotonic rule does not fall through to an insert.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_RejectedMonotonicUpdate_DoesNotFallThroughToInsert()
{
@@ -161,6 +173,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// any row existed and then skipped its INSERT, dropping the packet's
/// Status/RetryCount/HttpStatus/TerminalAtUtc on the floor.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_RowAlreadyCreatedByAnotherWriter_StillAppliesThisPacketsState()
{
@@ -215,6 +228,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// test itself is not flaky — only its coverage of the rare branch is
/// probabilistic.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_TwoConcurrentFirstWrites_NewerStateSurvives()
{
@@ -256,6 +270,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that upserting the same status with an equal update time is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_SameStatus_EqualUpdatedAt_IsNoOp()
{
@@ -272,7 +288,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// Same rank (2) AND an EQUAL UpdatedAtUtc — an idempotent replay of the
// same telemetry packet. The same-rank freshness tiebreaker only fires
// for a STRICTLY-newer stamp, so a true duplicate stays a no-op (no
// fields move). Task 11: newest-within-rank wins, equal stamps are inert.
// fields move). Newest-within-rank wins, equal stamps are inert.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second", updatedAtUtc: stamp));
var afterDuplicate = await repo.GetAsync(id);
@@ -283,6 +299,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc);
}
/// <summary>Verifies that a same-rank upsert with a newer update time refreshes the progress fields.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_SameRank_NewerUpdatedAt_RefreshesProgressFields()
{
@@ -295,7 +313,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// Two Attempted packets (rank 2) from one retrying call; the second
// carries a strictly-newer UpdatedAtUtc, so its live RetryCount/LastError
// must land — the row no longer freezes at first-Attempted (Task 11).
// must land — the row no longer freezes at first-Attempted.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "timeout #1", updatedAtUtc: t0));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));
@@ -306,6 +324,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
}
/// <summary>Verifies that a same-rank upsert with an older update time is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_SameRank_OlderUpdatedAt_IsNoOp()
{
@@ -316,7 +336,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await using var context = CreateContext();
var repo = new SiteCallAuditRepository(context);
// Land retry 5 (stamp t0+5), then an out-of-order retry-2 packet with an
// Land retry 5 (stamp t0+5), then an out-of-order packet for retry 2 with an
// OLDER stamp (t0). Same rank, older UpdatedAtUtc → rejected; stays at 5.
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "timeout #5", updatedAtUtc: t0.AddMinutes(5)));
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "timeout #2", updatedAtUtc: t0));
@@ -328,6 +348,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(t0.AddMinutes(5), row.UpdatedAtUtc);
}
/// <summary>Verifies that a lower-rank upsert is still a no-op even with a newer update time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_LowerRank_NewerUpdatedAt_IsStillNoOp()
{
@@ -350,13 +372,15 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, row.RetryCount);
}
/// <summary>Verifies that one terminal status cannot overwrite another terminal status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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. Task 11 note: the
// Terminal statuses share rank 3 and are mutually
// exclusive — Delivered cannot overwrite Parked. Note that the
// Delivered packet carries a STRICTLY-NEWER UpdatedAtUtc, yet the row
// stays Parked — the same-rank freshness tiebreaker is scoped to
// NON-terminal ranks (< 3), so terminal immutability is preserved.
@@ -377,6 +401,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc);
}
/// <summary>Verifies that concurrent inserts of the same id leave only one row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow()
{
@@ -406,6 +432,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(1, count);
}
/// <summary>Verifies that getting a known id returns its row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetAsync_KnownId_ReturnsRow()
{
@@ -422,6 +450,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(id, loaded!.TrackedOperationId);
}
/// <summary>Verifies that getting an unknown id returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task GetAsync_UnknownId_ReturnsNull()
{
@@ -434,6 +464,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(loaded);
}
/// <summary>Verifies that filtering by source site returns only the matching rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows()
{
@@ -457,10 +489,12 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
}
/// <summary>Verifies that filtering by tracked operation id returns exactly that row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_FilterByTrackedOperationId_ReturnsExactlyThatRow()
{
// Flow Debugger follow-ups Task 2: SiteCallQueryFilter.TrackedOperationId is
// SiteCallQueryFilter.TrackedOperationId is
// an exact PK match (the ?correlationId= deep link). The column is varchar(36)
// holding the "D"-format GUID string, so the predicate must compare as a
// string — a uniqueidentifier parameter would convert the column side and
@@ -491,6 +525,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Empty(none);
}
/// <summary>Verifies that keyset paging returns consecutive pages with no overlap.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_KeysetPaging_NoOverlap()
{
@@ -542,6 +578,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(5, allIds.Count);
}
/// <summary>Verifies that the stuck cutoff composes with keyset paging without producing empty pages.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages()
{
@@ -603,6 +641,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(stuckIds.ToHashSet(), returned);
}
/// <summary>Verifies that the purge removes rows that are both terminal and old.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
{
@@ -631,6 +671,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(await repo.GetAsync(oldId));
}
/// <summary>Verifies that the purge keeps non-terminal and recent rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent()
{
@@ -670,6 +712,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.NotNull(await repo.GetAsync(recentTerminalId));
}
/// <summary>Verifies that the purge slices a multi-day backlog into several bounded deletes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task PurgeTerminal_SlicesMultiDayBacklog_IntoBoundedDeletes()
{
@@ -735,6 +779,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
// --- KPI snapshot tests -------------------------------------------------
/// <summary>Verifies that the KPI computation counts buffered, parked, failed, delivered and stuck rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck()
{
@@ -789,6 +835,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25));
}
/// <summary>Verifies that the per-site KPI computation scopes counts to each site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite()
{
@@ -831,6 +879,8 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(b.OldestPendingAge);
}
/// <summary>Verifies that the per-node KPI computation scopes counts to each node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ComputePerNodeKpisAsync_ScopesCountsToEachNode()
{
@@ -930,14 +980,16 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
};
}
// --- SourceNode-stamping (Task 14) --------------------------------------
// --- SourceNode-stamping --------------------------------------
/// <summary>Verifies that a fresh insert persists the source node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): a fresh INSERT must persist the
// SourceNode stamping: 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();
@@ -951,12 +1003,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("node-a", loaded!.SourceNode);
}
/// <summary>Verifies that a later packet carrying a null source node does not blank the stored value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): the UPDATE uses
// SourceNode stamping: 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.
@@ -993,12 +1047,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that a non-null incoming source node overwrites the previous value when the rank advances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// SourceNode-stamping (Task 14): per the COALESCE(@SourceNode,
// SourceNode stamping: 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
@@ -1037,12 +1093,14 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that a later packet fills the source node when the insert left it null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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
// SourceNode stamping: 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
@@ -1093,8 +1151,10 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
/// </summary>
private sealed class DeleteCountingCommandInterceptor : DbCommandInterceptor
{
/// <summary>Gets the number of DELETE statements issued since the last reset.</summary>
public int DeleteCount { get; private set; }
/// <summary>Resets the DELETE count to zero.</summary>
public void Reset() => DeleteCount = 0;
private void CountIfDelete(DbCommand command)
@@ -1105,6 +1165,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <inheritdoc />
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
{
@@ -1112,6 +1173,7 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
return base.NonQueryExecuting(command, eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
@@ -14,26 +14,30 @@ 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).
// Regression coverage for repositories / InstanceLocator that lacked direct tests, and for
// inconsistent constructor null-guarding.
public class ExternalSystemRepositoryTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
private readonly ExternalSystemRepository _repository;
/// <summary>Initializes a new instance of the <c>ExternalSystemRepositoryTests</c> class.</summary>
public ExternalSystemRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new ExternalSystemRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that an added external system round-trips through get by id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddExternalSystem_AndGetById_RoundTrips()
{
@@ -46,6 +50,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Equal("Sys", loaded!.Name);
}
/// <summary>Verifies that the external system timeout seconds value round-trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddExternalSystem_TimeoutSeconds_RoundTrips()
{
@@ -61,6 +67,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Equal(15, loaded!.TimeoutSeconds);
}
/// <summary>Verifies that getting methods by external system id returns only the parent system's methods.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMethodsByExternalSystemId_FiltersByParent()
{
@@ -76,6 +84,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Single(methods);
}
/// <summary>Verifies that deleting a database connection removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteDatabaseConnection_RemovesEntity()
{
@@ -89,14 +99,17 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Null(await _repository.GetDatabaseConnectionByIdAsync(conn.Id));
}
/// <summary>Verifies that constructing an ExternalSystemRepository with a null context throws an ArgumentNullException.</summary>
[Fact]
public void Constructor_NullContext_Throws()
{
Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!));
}
// ── ExternalSystemGateway-011: name-keyed repository lookups ──
// ── Name-keyed repository lookups ──
/// <summary>Verifies that getting an external system by name returns the matching row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExternalSystemByName_ReturnsMatchingRow()
{
@@ -113,6 +126,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Equal("https://beta.test", loaded.EndpointUrl);
}
/// <summary>Verifies that getting an external system by a missing name returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExternalSystemByName_MissingName_ReturnsNull()
{
@@ -123,6 +138,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Null(await _repository.GetExternalSystemByNameAsync("DoesNotExist"));
}
/// <summary>Verifies that getting a method by name returns the method scoped to its parent system.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMethodByName_ReturnsMethodScopedToParentSystem()
{
@@ -147,6 +164,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Equal("/b", method.Path);
}
/// <summary>Verifies that getting a method by a missing name returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMethodByName_MissingMethod_ReturnsNull()
{
@@ -157,6 +176,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Null(await _repository.GetMethodByNameAsync(sys.Id, "noSuchMethod"));
}
/// <summary>Verifies that getting a database connection by name returns the matching row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDatabaseConnectionByName_ReturnsMatchingRow()
{
@@ -173,6 +194,8 @@ public class ExternalSystemRepositoryTests : IDisposable
Assert.Equal("Server=hist;Database=h;", loaded.ConnectionString);
}
/// <summary>Verifies that getting a database connection by a missing name returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDatabaseConnectionByName_MissingName_ReturnsNull()
{
@@ -189,18 +212,22 @@ public class NotificationRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly NotificationRepository _repository;
/// <summary>Initializes a new instance of the <c>NotificationRepositoryTests</c> class.</summary>
public NotificationRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new NotificationRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that an added notification list with recipients round-trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddNotificationList_WithRecipients_RoundTrips()
{
@@ -217,6 +244,8 @@ public class NotificationRepositoryTests : IDisposable
Assert.Single(all[0].Recipients);
}
/// <summary>Verifies that an added SMTP configuration round-trips through get by id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddSmtpConfiguration_AndGetById_RoundTrips()
{
@@ -229,6 +258,8 @@ public class NotificationRepositoryTests : IDisposable
Assert.Equal("smtp.example.test", loaded!.Host);
}
/// <summary>Verifies that an added SMS configuration round-trips through get by id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddSmsConfiguration_AndGetById_RoundTrips()
{
@@ -241,6 +272,8 @@ public class NotificationRepositoryTests : IDisposable
Assert.Equal("ACbyid123", loaded!.AccountSid);
}
/// <summary>Verifies that deleting a notification list removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteNotificationList_RemovesEntity()
{
@@ -254,6 +287,8 @@ public class NotificationRepositoryTests : IDisposable
Assert.Null(await _repository.GetNotificationListByIdAsync(list.Id));
}
/// <summary>Verifies that an added SMS configuration round-trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddSmsConfiguration_RoundTrips()
{
@@ -282,6 +317,8 @@ public class NotificationRepositoryTests : IDisposable
Assert.Null(await _repository.GetSmsConfigurationAsync());
}
/// <summary>Verifies that a notification list persists its type.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NotificationList_PersistsType()
{
@@ -293,6 +330,7 @@ public class NotificationRepositoryTests : IDisposable
Assert.Equal(NotificationType.Email, loaded!.Type);
}
/// <summary>Verifies that constructing a NotificationRepository with a null context throws an ArgumentNullException.</summary>
[Fact]
public void Constructor_NullContext_Throws()
{
@@ -304,17 +342,21 @@ public class NotificationOutboxConfigurationTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Initializes a new instance of the <c>NotificationOutboxConfigurationTests</c> class.</summary>
public NotificationOutboxConfigurationTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that a fully populated notification round-trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Notification_FullyPopulated_RoundTrips()
{
@@ -375,10 +417,12 @@ public class NotificationOutboxConfigurationTests : IDisposable
Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId);
}
/// <summary>Verifies that a null origin execution id round-trips as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Notification_NullOriginExecutionId_RoundTripsAsNull()
{
// Audit Log #23: OriginExecutionId is an additive nullable column —
// 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();
@@ -395,6 +439,8 @@ public class NotificationOutboxConfigurationTests : IDisposable
Assert.Null(loaded!.OriginExecutionId);
}
/// <summary>Verifies that a null origin parent execution id round-trips as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Notification_NullOriginParentExecutionId_RoundTripsAsNull()
{
@@ -415,6 +461,8 @@ public class NotificationOutboxConfigurationTests : IDisposable
Assert.Null(loaded!.OriginParentExecutionId);
}
/// <summary>Verifies that the notification status is persisted as a string.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Notification_StatusPersistsAsString()
{
@@ -437,18 +485,20 @@ public class NotificationOutboxConfigurationTests : IDisposable
}
}
// Coverage for the Notification Outbox repository (Task 5 of the notification-outbox feature).
// Coverage for the Notification Outbox repository (notification-outbox feature).
public class NotificationOutboxRepositoryTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
private readonly NotificationOutboxRepository _repository;
/// <summary>Initializes a new instance of the <c>NotificationOutboxRepositoryTests</c> class.</summary>
public NotificationOutboxRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new NotificationOutboxRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
@@ -480,6 +530,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
// — 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.
/// <summary>Verifies that get-due returns pending and due retrying notifications, ordered by creation time and capped at the batch size.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDueAsync_ReturnsPendingAndDueRetrying_OrderedByCreatedAt_CappedAtBatchSize()
{
@@ -515,6 +567,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Equal(retryingDue.NotificationId, capped[1].NotificationId);
}
/// <summary>Verifies that get by id returns the row when present and null otherwise.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetByIdAsync_ReturnsRowOrNull()
{
@@ -527,6 +581,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Null(await _repository.GetByIdAsync("does-not-exist"));
}
/// <summary>Verifies that an update persists the status transition.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UpdateAsync_PersistsStatusTransition()
{
@@ -553,6 +609,7 @@ public class NotificationOutboxRepositoryTests : IDisposable
/// retry/discard one-shots) depend on it to avoid reporting success against a
/// purged notification.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UpdateAsync_RowPurgedBeforeWrite_ReturnsFalse()
{
@@ -573,6 +630,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.False(await _repository.UpdateAsync(loaded));
}
/// <summary>Verifies that a conditional update from Parked applies and returns true when the status is Parked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryUpdateFromParkedAsync_StatusIsParked_UpdatesAndReturnsTrue()
{
@@ -596,8 +655,9 @@ public class NotificationOutboxRepositoryTests : IDisposable
/// <summary>
/// Direct CAS-miss: the row is currently Delivered (never Parked in this
/// scenario), so the conditional WHERE Status == Parked matches nothing and
/// the row is left completely untouched — arch-review 2026-08-19 P5-4.
/// the row is left completely untouched.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryUpdateFromParkedAsync_StatusIsNotParked_ReturnsFalse_AndLeavesRowUnchanged()
{
@@ -617,6 +677,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Equal(deliveredAt, reloaded.DeliveredAt);
}
/// <summary>Verifies that a conditional update from Parked returns false when the row was purged before the write.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryUpdateFromParkedAsync_RowPurgedBeforeWrite_ReturnsFalse()
{
@@ -636,7 +698,7 @@ public class NotificationOutboxRepositoryTests : IDisposable
}
/// <summary>
/// Deterministic (non-sleep) proof of the arch-review 2026-08-19 P5-4 race:
/// Deterministic (non-sleep) proof of the race:
/// the operator reads a Parked row, but before its write lands the
/// dispatcher (or a concurrent second operator action) has already moved
/// the row to Delivered. <see cref="TryUpdateFromParkedAsync"/>'s CAS must
@@ -646,6 +708,7 @@ public class NotificationOutboxRepositoryTests : IDisposable
/// operator path used PRE-FIX — has no such guard, which is the mechanism
/// the finding describes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryUpdateFromParkedAsync_ConcurrentlyDelivered_ReturnsFalse_AndDoesNotOverwrite()
{
@@ -683,13 +746,14 @@ public class NotificationOutboxRepositoryTests : IDisposable
/// <see cref="NotificationOutboxRepository.UpdateAsync"/> instead — the
/// method the operator Retry/Discard path called pre-fix — and shows it
/// silently clobbers the Delivered row. This documents the exact defect
/// arch-review 2026-08-19 P5-4 fixed by routing the operator path through
/// fixed by routing the operator path through
/// <see cref="TryUpdateFromParkedAsync"/>: UpdateAsync itself is unchanged
/// (the dispatcher still needs an unconditional write), so this scenario
/// remains "true" by design — it is the operator actor's call-site choice
/// that was the bug, proven fixed by the actor-level tests in
/// NotificationOutbox.Tests (Discard_ConcurrentlyDelivered_...).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UpdateAsync_Unconditional_WouldOverwriteConcurrentlyDeliveredRow_DemonstratingP5_4Mechanism()
{
@@ -711,7 +775,7 @@ public class NotificationOutboxRepositoryTests : IDisposable
operatorRead!.Status = NotificationStatus.Discarded;
// The unconditional UPDATE has no status guard, so it "succeeds" here —
// this is the bug. It clobbers the Delivered row to Discarded and drops
// DeliveredAt, which is exactly the silent-overwrite hazard P5-4 closes
// DeliveredAt, which is exactly the silent-overwrite hazard the conditional update closes
// by moving the operator path to TryUpdateFromParkedAsync.
Assert.True(await _repository.UpdateAsync(operatorRead));
@@ -721,6 +785,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Null(reloaded.DeliveredAt);
}
/// <summary>Verifies that a query applies its filters, orders by creation time descending and paginates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_AppliesFilters_OrdersByCreatedAtDescending_AndPaginates()
{
@@ -755,6 +821,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Equal("Tank Level 0", page2[1].Subject);
}
/// <summary>Verifies that a query's subject keyword filter matches by containment.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_SubjectKeyword_UsesContains()
{
@@ -770,6 +838,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Equal("Tank Level High", rows[0].Subject);
}
/// <summary>Verifies that a stuck-only query returns non-terminal rows older than the cutoff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_StuckOnly_ReturnsNonTerminalRowsOlderThanCutoff()
{
@@ -798,6 +868,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
}
/// <summary>Verifies that a query's from/to filter applies to the creation time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_FromTo_FilterAgainstCreatedAt()
{
@@ -820,6 +892,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
}
/// <summary>Verifies that a query's status and type filters apply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_StatusAndTypeFilters()
{
@@ -838,6 +912,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Equal("Parked Row", rows[0].Subject);
}
/// <summary>Verifies that deleting terminal rows older than the cutoff removes only those rows and leaves the others.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteTerminalOlderThanAsync_DeletesTerminalRowsOlderThanCutoff_LeavesOthers()
{
@@ -867,6 +943,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Contains(oldPending.NotificationId, remaining);
}
/// <summary>Verifies that computing KPIs populates the snapshot fields.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeKpisAsync_ComputesSnapshotFields()
{
@@ -903,6 +981,8 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.True(kpis.OldestPendingAge!.Value >= TimeSpan.FromMinutes(115));
}
/// <summary>Verifies that computing KPIs with no non-terminal rows yields a null oldest-pending age.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ComputeKpisAsync_NoNonTerminalRows_OldestPendingAgeIsNull()
{
@@ -919,6 +999,7 @@ public class NotificationOutboxRepositoryTests : IDisposable
Assert.Null(kpis.OldestPendingAge);
}
/// <summary>Verifies that constructing a NotificationOutboxRepository with a null context throws an ArgumentNullException.</summary>
[Fact]
public void Constructor_NullContext_Throws()
{
@@ -931,18 +1012,22 @@ public class SiteRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly SiteRepository _repository;
/// <summary>Initializes a new instance of the <c>SiteRepositoryTests</c> class.</summary>
public SiteRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new SiteRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that an added site round-trips through get by identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddSite_AndGetByIdentifier_RoundTrips()
{
@@ -955,6 +1040,8 @@ public class SiteRepositoryTests : IDisposable
Assert.Equal("Site1", loaded!.Name);
}
/// <summary>Verifies that deleting a site through the stub-attach path removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteSite_ViaStubAttachPath_RemovesEntity()
{
@@ -972,6 +1059,8 @@ public class SiteRepositoryTests : IDisposable
Assert.Null(await _repository.GetSiteByIdAsync(id));
}
/// <summary>Verifies that deleting a data connection through the stub-attach path removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteDataConnection_ViaStubAttachPath_RemovesEntity()
{
@@ -991,6 +1080,8 @@ public class SiteRepositoryTests : IDisposable
Assert.Null(await _repository.GetDataConnectionByIdAsync(id));
}
/// <summary>Verifies that getting instances by site id returns only that site's instances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstancesBySiteId_FiltersBySite()
{
@@ -1007,6 +1098,8 @@ public class SiteRepositoryTests : IDisposable
Assert.Single(instances);
}
/// <summary>Verifies that getting instances referencing a data connection returns an empty list when there are no bindings.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty()
{
@@ -1025,6 +1118,8 @@ public class SiteRepositoryTests : IDisposable
Assert.Empty(result);
}
/// <summary>Verifies that getting instances referencing a data connection returns the instance bound to it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance()
{
@@ -1049,6 +1144,8 @@ public class SiteRepositoryTests : IDisposable
Assert.Equal(instance.Id, result[0].Id);
}
/// <summary>Verifies that getting instances referencing a data connection returns one instance when two bindings belong to the same instance.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne()
{
@@ -1077,6 +1174,7 @@ public class SiteRepositoryTests : IDisposable
Assert.Equal(instance.Id, result[0].Id);
}
/// <summary>Verifies that constructing a SiteRepository with a null context throws an ArgumentNullException.</summary>
[Fact]
public void Constructor_NullContext_Throws()
{
@@ -1093,6 +1191,7 @@ public class SiteRepositoryTests : IDisposable
/// up to date. (The bump is unattributed — a connection has no owning template —
/// so both the global and structure counters must move.)
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SaveChanges_DataConnectionEdit_BumpsWatermark()
{
@@ -1134,6 +1233,7 @@ public class SiteRepositoryTests : IDisposable
/// that touches no data connection must leave the watermark alone, or every
/// site/area edit would needlessly invalidate the whole flatten cache.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SaveChanges_WithoutDataConnectionChange_DoesNotBumpWatermark()
{
@@ -1154,12 +1254,14 @@ public class DeploymentManagerRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly DeploymentManagerRepository _repository;
/// <summary>Initializes a new instance of the <c>DeploymentManagerRepositoryTests</c> class.</summary>
public DeploymentManagerRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new DeploymentManagerRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
@@ -1180,6 +1282,8 @@ public class DeploymentManagerRepositoryTests : IDisposable
return instance;
}
/// <summary>Verifies that getting the current deployment status returns the most recently added record.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddDeploymentRecord_AndGetCurrentStatus_ReturnsMostRecent()
{
@@ -1196,10 +1300,12 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Equal("d-002", current!.DeploymentId);
}
/// <summary>Verifies that when records share a timestamp, the current deployment status picks the highest id as a deterministic tiebreaker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetCurrentDeploymentStatus_SameTickRecords_DeterministicTiebreakerPicksHighestId()
{
// DeploymentManager-026: deployments are insert-only, so two records for the
// Deployments are insert-only, so two records for the
// same instance can tie on DeployedAt when created within the same clock tick
// (rapid redeploy, or redeploy right after a timed-out attempt). Without a
// secondary sort key the "current" read is non-deterministic. The repository
@@ -1221,6 +1327,8 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Equal("d-new", current!.DeploymentId);
}
/// <summary>Verifies that deleting a deployment record through the stub-attach path removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity()
{
@@ -1239,7 +1347,7 @@ public class DeploymentManagerRepositoryTests : IDisposable
}
/// <summary>
/// ConfigurationDatabase-024: CD-017 added optimistic-concurrency to
/// Optimistic concurrency was added 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>.
@@ -1248,10 +1356,10 @@ public class DeploymentManagerRepositoryTests : IDisposable
/// admin / handler shape), calls Delete with that token, and the delete completes
/// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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")
{
@@ -1276,6 +1384,8 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
}
/// <summary>Verifies that deleting an instance first removes its deployment records, which have a restrict foreign key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
{
@@ -1293,10 +1403,12 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Empty(await _repository.GetDeploymentsByInstanceIdAsync(instance.Id));
}
/// <summary>Verifies that getting an instance by id includes its native alarm source overrides.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceByIdAsync_IncludesNativeAlarmSourceOverrides()
{
// Regression for #226: NativeAlarmSourceOverrides was omitted from the Include chain.
// Regression: NativeAlarmSourceOverrides was omitted from the Include chain.
var instance = await SeedInstanceAsync();
instance.NativeAlarmSourceOverrides.Add(
new InstanceNativeAlarmSourceOverride("src/AlarmA") { InstanceId = instance.Id });
@@ -1310,10 +1422,12 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Equal("src/AlarmA", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName);
}
/// <summary>Verifies that getting an instance by unique name includes its native alarm source overrides.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceByUniqueNameAsync_IncludesNativeAlarmSourceOverrides()
{
// Regression for #226: NativeAlarmSourceOverrides was omitted from the Include chain.
// Regression: NativeAlarmSourceOverrides was omitted from the Include chain.
var instance = await SeedInstanceAsync();
instance.NativeAlarmSourceOverrides.Add(
new InstanceNativeAlarmSourceOverride("src/AlarmB") { InstanceId = instance.Id });
@@ -1327,6 +1441,7 @@ public class DeploymentManagerRepositoryTests : IDisposable
Assert.Equal("src/AlarmB", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName);
}
/// <summary>Verifies that constructing a DeploymentManagerRepository with a null context throws an ArgumentNullException.</summary>
[Fact]
public void Constructor_NullContext_Throws()
{
@@ -1339,18 +1454,22 @@ public class InstanceLocatorTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly InstanceLocator _locator;
/// <summary>Initializes a new instance of the <c>InstanceLocatorTests</c> class.</summary>
public InstanceLocatorTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_locator = new InstanceLocator(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that getting the site id for an existing instance returns the site identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteIdForInstance_WhenFound_ReturnsSiteIdentifier()
{
@@ -1367,6 +1486,8 @@ public class InstanceLocatorTests : IDisposable
Assert.Equal("SITE-001", result);
}
/// <summary>Verifies that getting the site id for a missing instance returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull()
{
@@ -1374,6 +1495,7 @@ public class InstanceLocatorTests : IDisposable
Assert.Null(result);
}
/// <summary>Verifies that constructing an InstanceLocator with a null context throws an ArgumentNullException.</summary>
[Fact]
public void Constructor_NullContext_Throws()
{
@@ -17,6 +17,7 @@ public class SecurityRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly SecurityRepository _repository;
/// <summary>Initializes the test class with a fresh in-memory database context.</summary>
public SecurityRepositoryTests()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -29,12 +30,15 @@ public class SecurityRepositoryTests : IDisposable
_repository = new SecurityRepository(_context);
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that an added mapping can be retrieved by its id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddMapping_AndGetById_ReturnsMapping()
{
@@ -48,6 +52,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.Equal("Administrator", loaded.Role);
}
/// <summary>Verifies that getting all mappings returns every mapping.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAllMappings_ReturnsAll()
{
@@ -60,6 +66,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.True(all.Count >= 2);
}
/// <summary>Verifies that getting mappings by role returns only the matching mappings.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetMappingsByRole_FiltersCorrectly()
{
@@ -73,6 +81,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.Contains(designMappings, m => m.LdapGroupName == "Designers");
}
/// <summary>Verifies that updating a mapping persists the change.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UpdateMapping_PersistsChange()
{
@@ -89,6 +99,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.Equal("Designer", loaded!.Role);
}
/// <summary>Verifies that deleting a mapping removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteMapping_RemovesEntity()
{
@@ -103,6 +115,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.Null(loaded);
}
/// <summary>Verifies that an added scope rule can be retrieved for its mapping.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddScopeRule_AndGetForMapping()
{
@@ -121,6 +135,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.Equal(site.Id, rules[0].SiteId);
}
/// <summary>Verifies that getting a scope rule by id returns the rule.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetScopeRuleById_ReturnsRule()
{
@@ -139,6 +155,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.Equal(mapping.Id, loaded.LdapGroupMappingId);
}
/// <summary>Verifies that updating a scope rule persists the change.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UpdateScopeRule_PersistsChange()
{
@@ -162,6 +180,8 @@ public class SecurityRepositoryTests : IDisposable
Assert.Equal(site2.Id, loaded!.SiteId);
}
/// <summary>Verifies that deleting a scope rule removes the entity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeleteScopeRule_RemovesEntity()
{
@@ -188,18 +208,22 @@ public class CentralUiRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly CentralUiRepository _repository;
/// <summary>Initializes the test class with a fresh in-memory database context.</summary>
public CentralUiRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new CentralUiRepository(_context);
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that getting all sites returns them ordered by name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAllSites_ReturnsOrderedByName()
{
@@ -214,6 +238,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Equal("Zulu", sites[1].Name);
}
/// <summary>Verifies that filtered instances can be restricted by site id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstancesFiltered_BySiteId()
{
@@ -234,6 +260,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Equal("Inst1", instances[0].UniqueName);
}
/// <summary>Verifies that filtered instances can be restricted by search term.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstancesFiltered_BySearchTerm()
{
@@ -252,6 +280,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Single(instances);
}
/// <summary>Verifies that recent deployments are returned in reverse chronological order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetRecentDeployments_ReturnsInReverseChronological()
{
@@ -277,6 +307,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Equal("d-002", recent[1].DeploymentId);
}
/// <summary>Verifies that audit log entries can be filtered by user.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_FiltersByUser()
{
@@ -291,6 +323,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Equal("admin", entries[0].User);
}
/// <summary>Verifies that audit log entries can be filtered by entity type.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_FiltersByEntityType()
{
@@ -304,6 +338,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Equal(1, total);
}
/// <summary>Verifies that audit log entries can be filtered by action type.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_FiltersByActionType()
{
@@ -316,6 +352,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Single(entries);
}
/// <summary>Verifies that audit log entries can be filtered by time range.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_FiltersByTimeRange()
{
@@ -329,6 +367,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Single(entries);
}
/// <summary>Verifies that audit log entries can be filtered by entity id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_FiltersByEntityId()
{
@@ -341,6 +381,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Single(entries);
}
/// <summary>Verifies that audit log entries can be filtered by entity name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_FiltersByEntityName()
{
@@ -353,6 +395,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Single(entries);
}
/// <summary>Verifies that audit log entries can be filtered by bundle import id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_FiltersByBundleImportId()
{
@@ -376,6 +420,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Equal(importA, entries[0].BundleImportId);
}
/// <summary>Verifies that audit log entries are returned in reverse chronological order with pagination.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAuditLogEntries_ReverseChronologicalWithPagination()
{
@@ -399,6 +445,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Equal("T6", page2[0].EntityName);
}
/// <summary>Verifies that the template tree includes child entities.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplateTree_IncludesChildren()
{
@@ -413,6 +461,8 @@ public class CentralUiRepositoryTests : IDisposable
Assert.Single(loaded.Attributes);
}
/// <summary>Verifies that the area tree returns the area hierarchy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAreaTree_ReturnsHierarchy()
{
@@ -18,21 +18,25 @@ public class SchemaConfigurationTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Creates an in-memory SQLite context for each test.</summary>
public SchemaConfigurationTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
// ConfigurationDatabase-006: the gRPC node-address columns must be length-bounded
// 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).
/// <summary>Verifies that the gRPC node address columns are limited to 500 characters.</summary>
/// <param name="propertyName">The gRPC node address property under test.</param>
[Theory]
[InlineData(nameof(Site.GrpcNodeAAddress))]
[InlineData(nameof(Site.GrpcNodeBAddress))]
@@ -45,6 +49,8 @@ public class SchemaConfigurationTests : IDisposable
Assert.Equal(500, property.GetMaxLength());
}
/// <summary>Verifies that the gRPC node address columns match the length bound of the sibling node address columns.</summary>
/// <param name="siblingPropertyName">The sibling node address property to compare against.</param>
[Theory]
[InlineData(nameof(Site.NodeAAddress))]
[InlineData(nameof(Site.NodeBAddress))]
@@ -57,12 +63,15 @@ public class SchemaConfigurationTests : IDisposable
Assert.Equal(siblingMaxLength, entity.FindProperty(nameof(Site.GrpcNodeBAddress))!.GetMaxLength());
}
// ConfigurationDatabase-014: the encrypting value converter must be applied
// 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.
/// <summary>Verifies that every secret-bearing column has the encrypted string converter applied.</summary>
/// <param name="entityType">The entity type that owns the column.</param>
/// <param name="propertyName">The secret-bearing property under test.</param>
[Theory]
[InlineData(typeof(SmtpConfiguration), nameof(SmtpConfiguration.Credentials))]
[InlineData(typeof(ExternalSystemDefinition), nameof(ExternalSystemDefinition.AuthConfiguration))]
@@ -82,6 +91,7 @@ public class SchemaConfigurationTests : IDisposable
// A filtered index over just the non-terminal rows turns each count from a clustered
// scan of the 365-day archive into a small seek. Locked shape:
// key = CreatedAtUtc, filter = [TerminalAtUtc] IS NULL, INCLUDE SourceSite/SourceNode/Status.
/// <summary>Verifies that the site calls table has a filtered index over non-terminal rows.</summary>
[Fact]
public void SiteCalls_Has_Filtered_NonTerminal_Index()
{
@@ -100,9 +110,10 @@ public class SchemaConfigurationTests : IDisposable
// arch-review 04 round 2 (R6): the daily terminal retention purge filters on
// TerminalAtUtc IS NOT NULL AND TerminalAtUtc < cutoff. A filtered index over the
// terminal population lets the purge (and Task 11's MIN() slice anchor) seek the
// terminal population lets the purge (and the MIN() slice anchor) seek the
// expired tail instead of full-scanning the 365-day table. Locked shape:
// key = TerminalAtUtc, filter = [TerminalAtUtc] IS NOT NULL.
/// <summary>Verifies that the site calls table has a filtered index over terminal rows.</summary>
[Fact]
public void SiteCalls_Has_Filtered_Terminal_Index()
{
@@ -119,6 +130,8 @@ public class SchemaConfigurationTests : IDisposable
// .IsMesReportableOverride (null = inherit) must both round-trip through the
// configuration database. Backed on SQL Server by the AddAlarmMesReportable
// migration; pinned here on the model/persist path.
/// <summary>Verifies that template alarms and their overrides persist the MES-reportable flags.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateAlarm_And_Override_PersistMesReportableFlags()
{
@@ -159,22 +172,26 @@ public class SplitQueryBehaviourTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly TemplateEngineRepository _repository;
/// <summary>Creates an in-memory SQLite context and a template repository for each test.</summary>
public SplitQueryBehaviourTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
// ConfigurationDatabase-009: the multi-collection eager-load queries were switched to
// 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.
/// <summary>Verifies that loading all templates with several members per collection returns every member without duplication.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetAllTemplatesAsync_WithMultipleMembersPerCollection_LoadsAllWithoutDuplication()
{
@@ -199,6 +216,8 @@ public class SplitQueryBehaviourTests : IDisposable
Assert.Equal(4, loaded.Scripts.Count);
}
/// <summary>Verifies that loading a template by id with several members populates all of its collections.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplateByIdAsync_WithMultipleMembers_LoadsAllCollections()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Regression guard for ConfigurationDatabase-004: secret-bearing columns
/// Regression guard: secret-bearing columns
/// (SMTP credentials, external-system auth config, database connection strings)
/// must be encrypted at rest, not persisted verbatim.
/// </summary>
@@ -17,6 +17,7 @@ public class SecretEncryptionTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly IDataProtectionProvider _protectionProvider;
/// <summary>Initializes a new instance of <c>SecretEncryptionTests</c>.</summary>
public SecretEncryptionTests()
{
_protectionProvider = new EphemeralDataProtectionProvider();
@@ -31,12 +32,15 @@ public class SecretEncryptionTests : IDisposable
_context.Database.EnsureCreated();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that database connection definition connection string stored encrypted round trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DatabaseConnectionDefinition_ConnectionString_StoredEncrypted_RoundTrips()
{
@@ -57,6 +61,8 @@ public class SecretEncryptionTests : IDisposable
Assert.Equal(secret, loaded.ConnectionString);
}
/// <summary>Verifies that smtp configuration credentials stored encrypted round trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SmtpConfiguration_Credentials_StoredEncrypted_RoundTrips()
{
@@ -78,6 +84,8 @@ public class SecretEncryptionTests : IDisposable
Assert.Equal(secret, loaded.Credentials);
}
/// <summary>Verifies that external system definition auth configuration stored encrypted round trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExternalSystemDefinition_AuthConfiguration_StoredEncrypted_RoundTrips()
{
@@ -99,6 +107,8 @@ public class SecretEncryptionTests : IDisposable
Assert.Equal(secret, loaded.AuthConfiguration);
}
/// <summary>Verifies that smtp configuration null credentials round trips as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SmtpConfiguration_NullCredentials_RoundTripsAsNull()
{
@@ -114,6 +124,8 @@ public class SecretEncryptionTests : IDisposable
Assert.Null(loaded.Credentials);
}
/// <summary>Verifies that sms configuration auth token stored encrypted round trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SmsConfiguration_AuthToken_StoredEncrypted_RoundTrips()
{
@@ -141,6 +153,8 @@ public class SecretEncryptionTests : IDisposable
Assert.Equal("AC0123456789", rawSid);
}
/// <summary>Verifies that sms configuration null auth token round trips as null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SmsConfiguration_NullAuthToken_RoundTripsAsNull()
{
@@ -8,8 +8,8 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Integration tests for <see cref="SecuredWriteRepository"/> (M7 OPC UA / MxGateway
/// UX, Task T14b). Uses the same <see cref="MsSqlMigrationFixture"/> as the migration
/// Integration tests for <see cref="SecuredWriteRepository"/> (OPC UA / MxGateway
/// secured writes). Uses the same <see cref="MsSqlMigrationFixture"/> as the migration
/// tests so the EF reads/writes execute against the real <c>PendingSecuredWrites</c>
/// schema produced by the migration. Each test mints a fresh per-test
/// <c>SiteId</c>/<c>Status</c> suffix so tests neither collide nor require teardown.
@@ -20,11 +20,15 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
/// <summary>Initializes the suite with the shared MS SQL migration fixture.</summary>
/// <param name="fixture">The shared MS SQL migration fixture.</param>
public SecuredWriteRepositoryTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
/// <summary>Verifies that a pending secured write round-trips through add, get, update and query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Lifecycle_AddGetUpdateQuery_RoundTrips()
{
@@ -92,6 +96,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
}
/// <summary>Verifies that null filters match every status for the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_NullFilters_MatchEveryStatusForSite()
{
@@ -109,6 +115,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Contains(all, p => p.Id == executedId);
}
/// <summary>Verifies that a pending row flips to expired exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task TryMarkExpiredAsync_PendingRow_FlipsToExpired_Once()
{
@@ -132,6 +140,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(row.VerifierUser); // system decision, no verifier
}
/// <summary>Verifies that marking an already decided row expired returns false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task TryMarkExpiredAsync_DecidedRow_ReturnsFalse()
{
@@ -148,6 +158,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.False(await repo.TryMarkExpiredAsync(id, DateTime.UtcNow));
}
/// <summary>Verifies that a pending row flips to rejected exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task TryMarkRejectedAsync_PendingRow_FlipsToRejected_Once()
{
@@ -177,10 +189,12 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(row.ExecutionError);
}
/// <summary>Verifies that when an approve is followed by a reject on one pending row, the approve wins and the reject returns false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task ApproveThenReject_OnOnePendingRow_ApproveWins_RejectReturnsFalse()
{
// arch-review 2026-08-19 P6-1: approve and reject both transition out of Pending,
// Approve and reject both transition out of Pending,
// so exactly one may win. This is the ordering that used to falsify the
// two-person-control record — a blind reject update landing after approve had
// already committed (and, in production, while its multi-second site relay was
@@ -205,6 +219,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("ok", row.VerifierComment);
}
/// <summary>Verifies that when a reject is followed by an approve on one pending row, the reject wins and the approve returns false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task RejectThenApprove_OnOnePendingRow_RejectWins_ApproveReturnsFalse()
{
@@ -229,6 +245,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal("no", row.VerifierComment);
}
/// <summary>Verifies that rejecting an expired row returns false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task TryMarkRejectedAsync_ExpiredRow_ReturnsFalse()
{
@@ -250,6 +268,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Null(row.VerifierUser);
}
/// <summary>Verifies that the count applies the same filters as the query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task CountAsync_FiltersLikeQueryAsync()
{
@@ -266,6 +286,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(0, await repo.CountAsync(status: "Executed", siteId: siteId));
}
/// <summary>Verifies that a query with permitted site ids returns only rows for those sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_PermittedSiteIds_ReturnsOnlyPermittedRows()
{
@@ -290,6 +312,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.DoesNotContain(rows, r => r.SiteId == site2);
}
/// <summary>Verifies that a count with permitted site ids counts only rows for those sites.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task CountAsync_PermittedSiteIds_CountsOnlyPermittedRows()
{
@@ -311,6 +335,8 @@ public class SecuredWriteRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(3, count);
}
/// <summary>Verifies that a null permitted-site list leaves the query unfiltered, for backward compatibility.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task QueryAsync_NullPermittedSiteIds_IsUnfiltered_BackCompat()
{
@@ -7,6 +7,7 @@ public class SeedDataTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Initializes an in-memory SQLite database with the schema and seed data applied.</summary>
public SeedDataTests()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -18,12 +19,15 @@ public class SeedDataTests : IDisposable
_context.Database.EnsureCreated();
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that the seeded administrator LDAP group mapping exists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SeedData_AdminMappingExists()
{
@@ -31,7 +35,7 @@ public class SeedDataTests : IDisposable
.SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins");
Assert.NotNull(adminMapping);
// Role VALUE canonicalized to "Administrator" (Task 1.7); group NAME unchanged.
// Role VALUE canonicalized to "Administrator"; group NAME unchanged.
Assert.Equal("Administrator", adminMapping.Role);
Assert.Equal(1, adminMapping.Id);
}
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
public class ServiceCollectionExtensionsTests
{
/// <summary>Verifies that adding the configuration database with a connection string registers the repositories and services.</summary>
[Fact]
public void AddConfigurationDatabase_WithConnectionString_RegistersRepositoriesAndServices()
{
@@ -22,10 +23,11 @@ public class ServiceCollectionExtensionsTests
Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository));
}
/// <summary>Verifies that the configuration database is configured with a retrying execution strategy.</summary>
[Fact]
public void AddConfigurationDatabase_Configures_RetryingExecutionStrategy()
{
// Task 12 (arch-review 04 S5): the central DbContext must survive transient
// The central DbContext must survive transient
// SQL faults. EnableRetryOnFailure installs a non-null ExecutionStrategyFactory
// on the SqlServer options extension — covering every read-side path (KPI asks,
// outbox queries, Tracking.Status, execution trees) that previously surfaced
@@ -130,10 +132,11 @@ public class ServiceCollectionExtensionsTests
types: new[] { typeof(IServiceCollection) },
modifiers: null)!;
/// <summary>Verifies that the parameterless overload fails fast with a clear message.</summary>
[Fact]
public void AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage()
{
// Regression guard for ConfigurationDatabase-003: the parameterless overload must not
// Regression guard for the original no-op bug: 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();
@@ -145,6 +148,7 @@ public class ServiceCollectionExtensionsTests
Assert.Contains("connection string", ex.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that the parameterless overload is marked obsolete as a compile-time error.</summary>
[Fact]
public void AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError()
{
@@ -7,8 +7,8 @@ using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// Round-trip tests for <see cref="SharedSchemaRepository"/> (M9 template-level JSON-Schema
/// library, Task T32a). Uses the in-memory SQLite harness (<see cref="SqliteTestHelper"/>)
/// Round-trip tests for <see cref="SharedSchemaRepository"/> (template-level JSON-Schema
/// library). Uses the in-memory SQLite harness (<see cref="SqliteTestHelper"/>)
/// — the schema is materialized via <c>EnsureCreated()</c> from the same model the
/// migration was generated from, so the tests run unconditionally (no live MSSQL needed)
/// while still exercising the real unique-<c>Name</c> index. Mirrors the
@@ -19,18 +19,22 @@ public class SharedSchemaRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly SharedSchemaRepository _repository;
/// <summary>Initializes a new instance of <c>SharedSchemaRepositoryTests</c>.</summary>
public SharedSchemaRepositoryTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new SharedSchemaRepository(_context);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that add then get by name round trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Add_ThenGetByName_RoundTrips()
{
@@ -58,6 +62,8 @@ public class SharedSchemaRepositoryTests : IDisposable
Assert.Equal("TankReading", byId!.Name);
}
/// <summary>Verifies that get by name unknown returns null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetByName_Unknown_ReturnsNull()
{
@@ -65,6 +71,8 @@ public class SharedSchemaRepositoryTests : IDisposable
Assert.Null(missing);
}
/// <summary>Verifies that add duplicate name throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Add_DuplicateName_Throws()
{
@@ -75,6 +83,8 @@ public class SharedSchemaRepositoryTests : IDisposable
_repository.AddAsync(new SharedSchema { Name = "Dup", SchemaJson = "{}" }));
}
/// <summary>Verifies that nullable scope round trips.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NullableScope_RoundTrips()
{
@@ -85,6 +95,8 @@ public class SharedSchemaRepositoryTests : IDisposable
Assert.Null(loaded!.Scope);
}
/// <summary>Verifies that list returns all ordered by name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task List_ReturnsAll_OrderedByName()
{
@@ -97,6 +109,8 @@ public class SharedSchemaRepositoryTests : IDisposable
Assert.Equal("Bravo", all[1].Name);
}
/// <summary>Verifies that update persists changes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Update_PersistsChanges()
{
@@ -114,6 +128,8 @@ public class SharedSchemaRepositoryTests : IDisposable
Assert.Equal("Updated", reloaded.Scope);
}
/// <summary>Verifies that delete removes row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Delete_RemovesRow()
{
@@ -14,16 +14,19 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
///
/// 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
/// throw on a secret-column write; passing a provider here
/// makes the test fixture's intent explicit at the call site.
/// </summary>
public class SqliteTestDbContext : ScadaBridgeDbContext
{
/// <summary>Initializes the context with the given SQLite options and an ephemeral data protection provider.</summary>
/// <param name="options">The EF options configured for the SQLite provider.</param>
public SqliteTestDbContext(DbContextOptions<ScadaBridgeDbContext> options)
: base(options, new EphemeralDataProtectionProvider())
{
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -38,7 +41,7 @@ public class SqliteTestDbContext : ScadaBridgeDbContext
.ValueGeneratedNever();
});
// Note (C5 / Task 2.5): the central dbo.AuditLog persisted computed columns
// Note: the central dbo.AuditLog persisted computed columns
// (JSON_VALUE-based) are neutralized for non-SQL-Server providers inside
// ScadaBridgeDbContext.OnModelCreating itself, so every SQLite test context —
// including ones that construct ScadaBridgeDbContext directly rather than via
@@ -74,6 +77,8 @@ public class SqliteTestDbContext : ScadaBridgeDbContext
public static class SqliteTestHelper
{
/// <summary>Creates a context backed by a fresh in-memory SQLite database with the schema created.</summary>
/// <returns>The open in-memory test context.</returns>
public static ScadaBridgeDbContext CreateInMemoryContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -92,6 +97,8 @@ public static class SqliteTestHelper
/// test observe the actual SQL a repository emits (e.g. count DELETE statements
/// issued by a batched purge).
/// </summary>
/// <param name="interceptors">The EF interceptors to attach to the context.</param>
/// <returns>The open in-memory test context with the interceptors attached.</returns>
public static ScadaBridgeDbContext CreateInMemoryContext(params IInterceptor[] interceptors)
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -106,6 +113,9 @@ public static class SqliteTestHelper
return context;
}
/// <summary>Creates a context backed by a SQLite database file.</summary>
/// <param name="dbPath">The path of the SQLite database file.</param>
/// <returns>The file-backed test context.</returns>
public static ScadaBridgeDbContext CreateFileContext(string dbPath)
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -11,6 +11,7 @@ public class TemplateEngineRepositoryTests : IDisposable
private readonly ScadaBridgeDbContext _context;
private readonly TemplateEngineRepository _repository;
/// <summary>Initializes a new instance of the <see cref="TemplateEngineRepositoryTests"/> class.</summary>
public TemplateEngineRepositoryTests()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
@@ -23,12 +24,15 @@ public class TemplateEngineRepositoryTests : IDisposable
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
}
/// <summary>Closes the connection and disposes the database context.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that the topology page is bounded, filtered and returned untracked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TopologyPage_IsBoundedFilteredAndUntracked()
{
@@ -53,6 +57,8 @@ public class TemplateEngineRepositoryTests : IDisposable
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => _repository.GetTopologyInstancesAsync(site.Id, null, 0, 51));
}
/// <summary>Verifies that loading a template with children returns it with all member collections populated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplateWithChildrenAsync_ReturnsTemplateWithAllMemberCollectionsPopulated()
{
@@ -73,7 +79,7 @@ public class TemplateEngineRepositoryTests : IDisposable
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.
// not silently drop them. Regression guard for dropped child members.
Assert.NotNull(loaded);
Assert.Equal(template.Id, loaded!.Id);
Assert.Single(loaded.Attributes);
@@ -86,10 +92,12 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Equal("Slot1", loaded.Compositions.First().InstanceName);
}
/// <summary>Verifies that a template script's execution timeout in seconds round-trips through EF.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateScript_ExecutionTimeoutSeconds_RoundTripsThroughEf()
{
// M2.5 (#9): the nullable per-script execution timeout must persist and
// The nullable per-script execution timeout must persist and
// reload through EF — both an explicit value and a null (use-global).
var template = new Template("TimeoutTemplate");
template.Scripts.Add(new TemplateScript("WithTimeout", "return 1;")
@@ -114,6 +122,8 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Null(noTimeout.ExecutionTimeoutSeconds);
}
/// <summary>Verifies that loading a template with children returns null when the template does not exist.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplateWithChildrenAsync_ReturnsNull_WhenTemplateDoesNotExist()
{
@@ -122,10 +132,12 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Null(loaded);
}
/// <summary>Verifies that the bulk variant fetches every matching template name in one query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplatesWithChildrenAsync_BulkVariant_FetchesEveryMatchingNameInOneQuery()
{
// Transport-008 regression: BundleImporter.PreviewAsync previously
// 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");
@@ -150,6 +162,8 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.DoesNotContain(result, t => t.Name == "Gamma");
}
/// <summary>Verifies that the bulk variant returns an empty result for an empty list of names.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplatesWithChildrenAsync_EmptyNames_ReturnsEmpty()
{
@@ -158,6 +172,8 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Empty(result);
}
/// <summary>Verifies that the bulk variant returns an empty result for a null enumerable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplatesWithChildrenAsync_NullEnumerable_ReturnsEmpty()
{
@@ -166,6 +182,8 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Empty(result);
}
/// <summary>Verifies that the bulk variant filters out duplicate and empty names.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplatesWithChildrenAsync_FiltersOutDuplicatesAndEmptyStrings()
{
@@ -182,6 +200,8 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Equal("Alpha", result[0].Name);
}
/// <summary>Verifies that the parent template id is preserved so the inheritance chain can be walked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk()
{
@@ -201,6 +221,8 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Equal(baseTemplate.Id, loaded!.ParentTemplateId);
}
/// <summary>Verifies that native alarm sources added for a template round-trip when queried by template id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddAndGetNativeAlarmSourcesByTemplateId_RoundTrips()
{
@@ -217,6 +239,8 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Equal("S", list[0].Name);
}
/// <summary>Verifies that loading a template with children includes its native alarm sources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetTemplateWithChildren_IncludesNativeAlarmSources()
{
@@ -19,17 +19,20 @@ public class DbContextTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Creates the test class with a fresh in-memory database context.</summary>
public DbContextTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that the schema creates all expected tables.</summary>
[Fact]
public void Schema_CreatesAllTables()
{
@@ -69,6 +72,7 @@ public class DbContextTests : IDisposable
Assert.Empty(_context.AuditLogEntries.ToList());
}
/// <summary>Verifies that a template and its child entities are created together.</summary>
[Fact]
public void Template_WithChildren_CascadeCreated()
{
@@ -95,6 +99,7 @@ public class DbContextTests : IDisposable
Assert.Equal("Attr1", loaded.Attributes.First().Name);
}
/// <summary>Verifies that a template can reference a parent template through inheritance.</summary>
[Fact]
public void Template_Inheritance_SelfReference()
{
@@ -110,6 +115,7 @@ public class DbContextTests : IDisposable
Assert.Equal(parent.Id, loaded.ParentTemplateId);
}
/// <summary>Verifies that a template composition creates the relationship between templates.</summary>
[Fact]
public void Template_Composition_CreatesRelationship()
{
@@ -129,6 +135,7 @@ public class DbContextTests : IDisposable
Assert.Equal(composedTemplate.Id, loaded.Compositions.First().ComposedTemplateId);
}
/// <summary>Verifies that an instance persists its overrides and connection bindings.</summary>
[Fact]
public void Instance_WithOverridesAndBindings()
{
@@ -163,6 +170,7 @@ public class DbContextTests : IDisposable
Assert.Equal(InstanceState.Enabled, loaded.State);
}
/// <summary>Verifies that a deployment record persists with all its fields.</summary>
[Fact]
public void DeploymentRecord_CreatesWithAllFields()
{
@@ -192,6 +200,7 @@ public class DbContextTests : IDisposable
Assert.Equal("abc123", loaded.RevisionHash);
}
/// <summary>Verifies that an audit log entry can be written and queried.</summary>
[Fact]
public void AuditLogEntry_WritesAndQueries()
{
@@ -209,6 +218,7 @@ public class DbContextTests : IDisposable
Assert.NotNull(loaded.AfterStateJson);
}
/// <summary>Verifies that an external system persists with its methods.</summary>
[Fact]
public void ExternalSystem_WithMethods()
{
@@ -230,6 +240,7 @@ public class DbContextTests : IDisposable
Assert.Single(_context.ExternalSystemMethods.Where(m => m.ExternalSystemDefinitionId == system.Id));
}
/// <summary>Verifies that a notification list persists with its recipients.</summary>
[Fact]
public void NotificationList_WithRecipients()
{
@@ -247,6 +258,7 @@ public class DbContextTests : IDisposable
Assert.Equal(2, loaded.Recipients.Count);
}
/// <summary>Verifies that an LDAP group mapping persists with its site scope rules.</summary>
[Fact]
public void Security_LdapGroupMapping_WithSiteScopeRules()
{
@@ -265,6 +277,7 @@ public class DbContextTests : IDisposable
Assert.Single(_context.SiteScopeRules.Where(r => r.LdapGroupMappingId == mapping.Id));
}
/// <summary>Verifies that an inbound API method persists.</summary>
[Fact]
public void InboundApi_Method()
{
@@ -279,6 +292,7 @@ public class DbContextTests : IDisposable
Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus"));
}
/// <summary>Verifies that areas form a parent-child hierarchy.</summary>
[Fact]
public void Area_HierarchyWorks()
{
@@ -302,6 +316,7 @@ public class DbContextTests : IDisposable
Assert.Equal("Floor 1", loaded.Children.First().Name);
}
/// <summary>Verifies that a data connection belongs to its site.</summary>
[Fact]
public void DataConnection_BelongsToSite()
{
@@ -317,6 +332,7 @@ public class DbContextTests : IDisposable
Assert.Equal(site.Id, loaded.SiteId);
}
/// <summary>Verifies that enum properties are stored as strings.</summary>
[Fact]
public void EnumProperties_StoredAsStrings()
{
@@ -334,6 +350,7 @@ public class DbContextTests : IDisposable
Assert.Equal(AlarmTriggerType.RangeViolation, alarm.TriggerType);
}
/// <summary>Verifies that the unique constraint on template names is enforced.</summary>
[Fact]
public void UniqueConstraint_Template_Name_Enforced()
{
@@ -344,6 +361,7 @@ public class DbContextTests : IDisposable
Assert.ThrowsAny<Exception>(() => _context.SaveChanges());
}
/// <summary>Verifies that date-time offset values are mapped correctly.</summary>
[Fact]
public void DateTimeOffset_MappedCorrectly()
{
@@ -361,6 +379,7 @@ public class DbContextTests : IDisposable
public class ServiceRegistrationTests
{
/// <summary>Verifies that registering the configuration database with a connection string registers the database context.</summary>
[Fact]
public void AddConfigurationDatabase_WithConnectionString_RegistersDbContext()
{
@@ -373,10 +392,11 @@ public class ServiceRegistrationTests
Assert.NotNull(context);
}
/// <summary>Verifies that the no-argument registration overload fails fast.</summary>
[Fact]
public void AddConfigurationDatabase_NoArgs_FailsFast()
{
// ConfigurationDatabase-003: the no-arg overload previously silently registered
// 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
@@ -399,6 +419,7 @@ public class MigrationHelperTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
/// <summary>Creates the test class with a fresh in-memory database context.</summary>
public MigrationHelperTests()
{
// Use SQLite with PendingModelChangesWarning suppressed because the migration
@@ -412,12 +433,15 @@ public class MigrationHelperTests : IDisposable
_context.Database.OpenConnection();
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
/// <summary>Verifies that production mode throws when migrations are pending.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyOrValidate_ProductionMode_WithPendingMigrations_Throws()
{
@@ -429,6 +453,7 @@ public class MigrationHelperTests : IDisposable
Assert.Contains("pending migration", ex.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that the initial create migration exists.</summary>
[Fact]
public void MigrationExists_InitialCreate()
{