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:
+21
-12
@@ -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()
|
||||
{
|
||||
|
||||
+20
-2
@@ -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()
|
||||
{
|
||||
|
||||
+11
-2
@@ -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()
|
||||
{
|
||||
|
||||
+4
@@ -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()
|
||||
{
|
||||
|
||||
+39
-1
@@ -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()
|
||||
{
|
||||
|
||||
+6
-1
@@ -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
-1
@@ -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()
|
||||
{
|
||||
|
||||
+24
-8
@@ -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()
|
||||
{
|
||||
|
||||
+15
-1
@@ -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()
|
||||
{
|
||||
|
||||
+12
@@ -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)]
|
||||
|
||||
+4
@@ -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()
|
||||
{
|
||||
|
||||
+10
-3
@@ -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)
|
||||
|
||||
+9
-1
@@ -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()
|
||||
{
|
||||
|
||||
+5
-1
@@ -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()
|
||||
{
|
||||
|
||||
+28
@@ -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()
|
||||
{
|
||||
|
||||
+94
-18
@@ -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(
|
||||
|
||||
+12
-1
@@ -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()
|
||||
{
|
||||
|
||||
+28
@@ -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();
|
||||
|
||||
+10
@@ -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()
|
||||
{
|
||||
|
||||
+59
-11
@@ -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)
|
||||
|
||||
+12
-2
@@ -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()
|
||||
{
|
||||
|
||||
+6
@@ -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()
|
||||
{
|
||||
|
||||
+75
-13
@@ -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)
|
||||
|
||||
+136
-14
@@ -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()
|
||||
{
|
||||
|
||||
+23
-4
@@ -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()
|
||||
{
|
||||
|
||||
+29
-3
@@ -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);
|
||||
}
|
||||
|
||||
+6
-2
@@ -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()
|
||||
{
|
||||
|
||||
+18
-2
@@ -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>()
|
||||
|
||||
+27
-3
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user