docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -14,8 +14,11 @@ public sealed class AuthorizationTests
{
private readonly SchemaComplianceFixture _fixture;
/// <summary>Initializes a new instance of AuthorizationTests with the schema compliance fixture.</summary>
/// <param name="fixture">The schema compliance test fixture.</param>
public AuthorizationTests(SchemaComplianceFixture fixture) => _fixture = fixture;
/// <summary>Verifies that the Node role can execute GetCurrentGenerationForCluster but not PublishGeneration.</summary>
[Fact]
public void Node_role_can_execute_GetCurrentGenerationForCluster_but_not_PublishGeneration()
{
@@ -48,6 +51,7 @@ public sealed class AuthorizationTests
}
}
/// <summary>Verifies that the Node role cannot SELECT from configuration tables directly.</summary>
[Fact]
public void Node_role_cannot_SELECT_from_tables_directly()
{
@@ -73,6 +77,7 @@ public sealed class AuthorizationTests
}
}
/// <summary>Verifies that the Admin role can execute PublishGeneration.</summary>
[Fact]
public void Admin_role_can_execute_PublishGeneration()
{
@@ -9,6 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class DraftValidatorTests
{
/// <summary>Verifies that UnsSegment validation rejects uppercase and special characters.</summary>
/// <param name="name">The segment name to validate.</param>
/// <param name="shouldPass">Whether the validation should pass for this name.</param>
[Theory]
[InlineData("valid-name", true)]
[InlineData("line-01", true)]
@@ -41,6 +44,7 @@ public sealed class DraftValidatorTests
hasUnsError.ShouldBe(!shouldPass);
}
/// <summary>Verifies that namespace cannot be bound to a different cluster.</summary>
[Fact]
public void Cross_cluster_namespace_binding_is_rejected()
{
@@ -55,6 +59,7 @@ public sealed class DraftValidatorTests
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
}
/// <summary>Verifies that namespace in the same cluster is accepted.</summary>
[Fact]
public void Same_cluster_namespace_binding_is_accepted()
{
@@ -68,6 +73,7 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding");
}
/// <summary>Verifies that equipment UUID must remain immutable across generations.</summary>
[Fact]
public void EquipmentUuid_change_across_generations_is_rejected()
{
@@ -85,6 +91,7 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable");
}
/// <summary>Verifies that a ZTag cannot be reserved by a different equipment UUID.</summary>
[Fact]
public void ZTag_reserved_by_different_uuid_is_rejected()
{
@@ -101,6 +108,7 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "BadDuplicateExternalIdentifier");
}
/// <summary>Verifies that equipment ID must be derived from its UUID.</summary>
[Fact]
public void EquipmentId_that_does_not_match_derivation_is_rejected()
{
@@ -114,6 +122,7 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
}
/// <summary>Verifies that Galaxy driver cannot be placed in Equipment namespace.</summary>
[Fact]
public void Galaxy_driver_in_Equipment_namespace_is_rejected()
{
@@ -127,6 +136,7 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch");
}
/// <summary>Verifies that all validation errors are reported simultaneously.</summary>
[Fact]
public void Draft_with_three_violations_surfaces_all_three()
{
@@ -150,6 +160,11 @@ public sealed class DraftValidatorTests
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
// ------------------------------------------------------------------------------------
/// <summary>Verifies that cluster topology validation checks node count against declared redundancy mode.</summary>
/// <param name="nodeCount">The declared cluster node count.</param>
/// <param name="mode">The declared redundancy mode.</param>
/// <param name="enabledNodes">The number of enabled nodes to include in the topology.</param>
/// <param name="expectedDeclaredErrors">The number of ClusterRedundancyModeInvalid errors expected.</param>
[Theory]
[InlineData(1, RedundancyMode.None, 1, 0)] // single-node standalone — ok
[InlineData(2, RedundancyMode.Warm, 2, 0)] // 2-node warm — ok
@@ -168,6 +183,7 @@ public sealed class DraftValidatorTests
errors.Count(e => e.Code == "ClusterRedundancyModeInvalid").ShouldBe(expectedDeclaredErrors);
}
/// <summary>Verifies that disabled nodes cause topology validation to fail.</summary>
[Fact]
public void ValidateClusterTopology_flags_disabled_node_mismatch()
{
@@ -188,6 +204,7 @@ public sealed class DraftValidatorTests
// ValidateClusterTopology_flags_multiple_Primary test (and the
// ClusterMultiplePrimary error code it asserted) were removed alongside Task 14d.
/// <summary>Verifies that a valid standalone cluster passes validation.</summary>
[Fact]
public void ValidateClusterTopology_returns_no_errors_on_valid_standalone()
{
@@ -227,6 +244,7 @@ public sealed class DraftValidatorTests
// ValidatePathLength — Enterprise/Site length precision (Configuration-003)
// ------------------------------------------------------------------------------------
/// <summary>Verifies that path length validation uses actual Enterprise and Site lengths.</summary>
[Fact]
public void PathLength_uses_actual_Enterprise_Site_when_provided()
{
@@ -270,6 +288,7 @@ public sealed class DraftValidatorTests
"actual Enterprise='zb' + Site='s' keeps total path at 161 chars — under the 200-char limit");
}
/// <summary>Verifies that path length validation uses conservative fallback when Enterprise and Site are absent.</summary>
[Fact]
public void PathLength_conservative_fallback_when_Enterprise_Site_absent()
{
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Collection(nameof(SchemaComplianceCollection))]
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
{
/// <summary>Verifies that the composite key allows the same host across different nodes or drivers.</summary>
[Fact]
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
{
@@ -56,6 +57,7 @@ public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
rows.ShouldContain(r => r.NodeId == "node-a" && r.DriverInstanceId == "modbus-plc1");
}
/// <summary>Verifies that the upsert pattern updates existing records in place.</summary>
[Fact]
public async Task Upsert_pattern_for_same_key_updates_in_place()
{
@@ -94,6 +96,7 @@ public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
(await ctx3.DriverHostStatuses.CountAsync(r => r.NodeId == "upsert-node")).ShouldBe(1);
}
/// <summary>Verifies that the State enum is persisted as a string, not an integer.</summary>
[Fact]
public async Task Enum_persists_as_string_not_int()
{
@@ -9,6 +9,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-{Guid.NewGuid():N}");
/// <summary>Cleans up temporary directory after test execution.</summary>
public void Dispose()
{
try
@@ -31,6 +32,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
PayloadJson = payload,
};
/// <summary>Verifies that reading a snapshot on first boot with no existing snapshot throws.</summary>
[Fact]
public async Task FirstBoot_NoSnapshot_ReadThrows()
{
@@ -40,6 +42,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
() => cache.ReadCurrentAsync("cluster-a"));
}
/// <summary>Verifies that sealed snapshots can be read back correctly.</summary>
[Fact]
public async Task SealThenRead_RoundTrips()
{
@@ -54,6 +57,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
read.PayloadJson.ShouldBe("{\"hello\":\"world\"}");
}
/// <summary>Verifies that sealed files are marked read-only on disk.</summary>
[Fact]
public async Task SealedFile_IsReadOnly_OnDisk()
{
@@ -66,6 +70,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
attrs.HasFlag(FileAttributes.ReadOnly).ShouldBeTrue("sealed file must be read-only");
}
/// <summary>Verifies that the current generation pointer advances when a new generation is sealed.</summary>
[Fact]
public async Task SealingTwoGenerations_PointerAdvances_ToLatest()
{
@@ -78,6 +83,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
read.GenerationId.ShouldBe(2);
}
/// <summary>Verifies that prior generation files are preserved after a new seal.</summary>
[Fact]
public async Task PriorGenerationFile_Survives_AfterNewSeal()
{
@@ -90,6 +96,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
File.Exists(Path.Combine(_root, "cluster-a", "2.db")).ShouldBeTrue();
}
/// <summary>Verifies that reading a corrupt sealed file fails safely.</summary>
[Fact]
public async Task CorruptSealedFile_ReadFailsClosed()
{
@@ -105,6 +112,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
() => cache.ReadCurrentAsync("cluster-a"));
}
/// <summary>Verifies that reading with a missing sealed file fails safely.</summary>
[Fact]
public async Task MissingSealedFile_ReadFailsClosed()
{
@@ -120,6 +128,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
() => cache.ReadCurrentAsync("cluster-a"));
}
/// <summary>Verifies that reading with a corrupt pointer file fails safely.</summary>
[Fact]
public async Task CorruptPointerFile_ReadFailsClosed()
{
@@ -133,6 +142,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
() => cache.ReadCurrentAsync("cluster-a"));
}
/// <summary>Verifies that sealing the same generation twice is idempotent.</summary>
[Fact]
public async Task SealSameGenerationTwice_IsIdempotent()
{
@@ -144,6 +154,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
read.PayloadJson.ShouldBe("{\"sample\":true}", "sealed file is immutable; second seal no-ops");
}
/// <summary>Verifies that independent clusters do not interfere with each other.</summary>
[Fact]
public async Task IndependentClusters_DoNotInterfere()
{
@@ -12,6 +12,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
/// <summary>Initializes a new instance of the LdapGroupRoleMappingServiceTests class.</summary>
public LdapGroupRoleMappingServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
@@ -20,6 +21,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
_db = new OtOpcUaConfigDbContext(options);
}
/// <summary>Disposes the database context.</summary>
public void Dispose() => _db.Dispose();
private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) =>
@@ -31,6 +33,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
IsSystemWide = isSystemWide ?? (clusterId is null),
};
/// <summary>Verifies that Create sets Id and CreatedAtUtc.</summary>
[Fact]
public async Task Create_SetsId_AndCreatedAtUtc()
{
@@ -43,6 +46,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
saved.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
}
/// <summary>Verifies that Create rejects empty LDAP group.</summary>
[Fact]
public async Task Create_Rejects_EmptyLdapGroup()
{
@@ -53,6 +57,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
() => svc.CreateAsync(row, CancellationToken.None));
}
/// <summary>Verifies that Create rejects system-wide mapping with ClusterId.</summary>
[Fact]
public async Task Create_Rejects_SystemWide_With_ClusterId()
{
@@ -63,6 +68,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
() => svc.CreateAsync(row, CancellationToken.None));
}
/// <summary>Verifies that Create rejects non-system-wide mapping without ClusterId.</summary>
[Fact]
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
{
@@ -73,6 +79,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
() => svc.CreateAsync(row, CancellationToken.None));
}
/// <summary>Verifies that GetByGroups returns only matching grants.</summary>
[Fact]
public async Task GetByGroups_Returns_MatchingGrants_Only()
{
@@ -88,6 +95,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
results.Select(r => r.Role).ShouldBe([AdminRole.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
}
/// <summary>Verifies that GetByGroups returns empty when input is empty.</summary>
[Fact]
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
{
@@ -99,6 +107,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
results.ShouldBeEmpty();
}
/// <summary>Verifies that ListAll orders results by group then cluster.</summary>
[Fact]
public async Task ListAll_Orders_ByGroupThenCluster()
{
@@ -115,6 +124,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
results[2].LdapGroup.ShouldBe("cn=b,dc=x");
}
/// <summary>Verifies that Delete removes the matching row.</summary>
[Fact]
public async Task Delete_Removes_Matching_Row()
{
@@ -127,6 +137,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
after.ShouldBeEmpty();
}
/// <summary>Verifies that Delete with unknown Id is a no-op.</summary>
[Fact]
public async Task Delete_Unknown_Id_IsNoOp()
{
@@ -9,6 +9,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
{
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db");
/// <summary>Cleans up the temporary database file.</summary>
public void Dispose()
{
if (File.Exists(_dbPath)) File.Delete(_dbPath);
@@ -22,6 +23,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
PayloadJson = $"{{\"g\":{gen}}}",
};
/// <summary>Verifies that payload is preserved through a write-then-read cycle.</summary>
[Fact]
public async Task Roundtrip_preserves_payload()
{
@@ -35,6 +37,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
got.PayloadJson.ShouldBe(put.PayloadJson);
}
/// <summary>Verifies that GetMostRecentAsync returns the latest generation when multiple exist.</summary>
[Fact]
public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
{
@@ -46,6 +49,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
got!.GenerationId.ShouldBe(20);
}
/// <summary>Verifies that GetMostRecentAsync returns null for an unknown cluster.</summary>
[Fact]
public async Task GetMostRecent_returns_null_for_unknown_cluster()
{
@@ -53,6 +57,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
(await cache.GetMostRecentAsync("ghost")).ShouldBeNull();
}
/// <summary>Verifies that Prune keeps the latest N generations and drops older ones.</summary>
[Fact]
public async Task Prune_keeps_latest_N_and_drops_older()
{
@@ -75,6 +80,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
count.ShouldBe(10);
}
/// <summary>Verifies that writing the same cluster/generation twice replaces rather than duplicates.</summary>
[Fact]
public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
{
@@ -95,6 +101,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
// not produce duplicate rows. The original find-then-insert was non-atomic so two racing
// callers could both observe `existing is null` and both Insert.
// ------------------------------------------------------------------------------------
/// <summary>Verifies that concurrent PutAsync calls for the same cluster and generation do not create duplicates.</summary>
[Fact]
public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate()
{
@@ -122,6 +129,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
$"PutAsync must upsert atomically — found {gen42Count} rows for (c-1, gen=42) after 64 concurrent puts");
}
/// <summary>Verifies that a corrupted cache file surfaces as LocalConfigCacheCorruptException.</summary>
[Fact]
public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
[Trait("Category", "Unit")]
public sealed class NodePermissionsTests
{
/// <summary>Verifies that the underlying type is int so it matches HasConversion in DbContext.</summary>
[Fact]
public void Underlying_type_is_int_so_it_matches_HasConversion_in_DbContext()
{
@@ -22,6 +23,7 @@ public sealed class NodePermissionsTests
typeof(NodePermissions).GetEnumUnderlyingType().ShouldBe(typeof(int));
}
/// <summary>Verifies that all defined bits round trip through int cast without loss.</summary>
[Fact]
public void All_defined_bits_round_trip_through_int_cast_without_loss()
{
@@ -35,6 +37,7 @@ public sealed class NodePermissionsTests
}
}
/// <summary>Verifies that bitwise combinations round trip through int storage.</summary>
[Fact]
public void Bitwise_combinations_round_trip_through_int_storage()
{
@@ -28,6 +28,7 @@ public sealed class Phase7ScriptingEntitiesTests
private static Microsoft.EntityFrameworkCore.Metadata.IModel DesignModel(OtOpcUaConfigDbContext ctx)
=> ctx.GetService<IDesignTimeModel>().Model;
/// <summary>Verifies that the Script entity is registered with the expected table name and columns.</summary>
[Fact]
public void Script_entity_registered_with_expected_table_and_columns()
{
@@ -46,6 +47,7 @@ public sealed class Phase7ScriptingEntitiesTests
.GetMaxLength().ShouldBe(16);
}
/// <summary>Verifies that the Script entity has a unique logical ID constraint.</summary>
[Fact]
public void Script_has_unique_logical_id()
{
@@ -55,6 +57,7 @@ public sealed class Phase7ScriptingEntitiesTests
entity.GetIndexes().Any(i => i.IsUnique).ShouldBeTrue("Script needs at least one unique index");
}
/// <summary>Verifies that the VirtualTag entity is registered with the expected trigger check constraint.</summary>
[Fact]
public void VirtualTag_entity_registered_with_trigger_check_constraint()
{
@@ -67,6 +70,7 @@ public sealed class Phase7ScriptingEntitiesTests
checks.ShouldContain("CK_VirtualTag_TimerInterval_Min");
}
/// <summary>Verifies that the VirtualTag entity has a unique index on its logical key.</summary>
[Fact]
public void VirtualTag_has_unique_index_on_logical_key()
{
@@ -76,6 +80,7 @@ public sealed class Phase7ScriptingEntitiesTests
entity.GetIndexes().Any(i => i.IsUnique).ShouldBeTrue("VirtualTag needs at least one unique index");
}
/// <summary>Verifies that the VirtualTag entity has ChangeTriggered, Historize, and TimerIntervalMs properties.</summary>
[Fact]
public void VirtualTag_has_ChangeTriggered_and_Historize_flags()
{
@@ -89,6 +94,7 @@ public sealed class Phase7ScriptingEntitiesTests
.ClrType.ShouldBe(typeof(int?));
}
/// <summary>Verifies that the ScriptedAlarm entity is registered with severity and type check constraints.</summary>
[Fact]
public void ScriptedAlarm_entity_registered_with_severity_and_type_checks()
{
@@ -101,6 +107,7 @@ public sealed class Phase7ScriptingEntitiesTests
checks.ShouldContain("CK_ScriptedAlarm_AlarmType");
}
/// <summary>Verifies that ScriptedAlarm has HistorizeToAveva defaulted to true per plan decision 15.</summary>
[Fact]
public void ScriptedAlarm_has_HistorizeToAveva_default_true_per_plan_decision_15()
{
@@ -120,6 +127,7 @@ public sealed class Phase7ScriptingEntitiesTests
alarm.Enabled.ShouldBeTrue();
}
/// <summary>Verifies that ScriptedAlarmState is keyed on ScriptedAlarmId and not generation-scoped.</summary>
[Fact]
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
{
@@ -136,6 +144,7 @@ public sealed class Phase7ScriptingEntitiesTests
"ack state follows alarm identity across generations");
}
/// <summary>Verifies that ScriptedAlarmState default values match Part 9 initial states.</summary>
[Fact]
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
{
@@ -152,6 +161,7 @@ public sealed class Phase7ScriptingEntitiesTests
state.LastAckUtc.ShouldBeNull();
}
/// <summary>Verifies that ScriptedAlarmState has a JSON check constraint on CommentsJson.</summary>
[Fact]
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
{
@@ -161,6 +171,7 @@ public sealed class Phase7ScriptingEntitiesTests
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
}
/// <summary>Verifies that all new Phase 7 entities are exposed via DbSet properties.</summary>
[Fact]
public void All_new_entities_exposed_via_DbSet()
{
@@ -171,6 +182,7 @@ public sealed class Phase7ScriptingEntitiesTests
ctx.ScriptedAlarmStates.ShouldNotBeNull();
}
/// <summary>Verifies that the AddPhase7ScriptingTables migration exists in the assembly.</summary>
[Fact]
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
{
@@ -12,6 +12,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}");
/// <summary>Disposes temporary test files.</summary>
public void Dispose()
{
try
@@ -24,6 +25,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
catch { /* best-effort */ }
}
/// <summary>Verifies that successful central DB reads return value and mark fresh.</summary>
[Fact]
public async Task CentralDbSucceeds_ReturnsValue_MarksFresh()
{
@@ -42,6 +44,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag");
}
/// <summary>Verifies that exhausted retries fall back to cache and mark stale.</summary>
[Fact]
public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale()
{
@@ -74,6 +77,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true");
}
/// <summary>Verifies that DB failure with unavailable cache throws.</summary>
[Fact]
public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
{
@@ -94,6 +98,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was");
}
/// <summary>Verifies that cancellation is not retried.</summary>
[Fact]
public async Task Cancellation_NotRetried()
{
@@ -127,6 +132,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
// must fall back to the sealed cache, not propagate as caller cancellation.
// ------------------------------------------------------------------------------------
/// <summary>Verifies that command timeout TaskCanceledException falls back to cache.</summary>
[Fact]
public async Task CommandTimeout_TaskCanceledException_FallsBackToCache()
{
@@ -156,6 +162,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag");
}
/// <summary>Verifies that Polly timeout rejection falls back to cache.</summary>
[Fact]
public async Task PollyTimeout_TimeoutRejectedException_FallsBackToCache()
{
@@ -193,6 +200,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
// exception chain). Project rule: no credential or connection-string fragment in logs.
// ------------------------------------------------------------------------------------
/// <summary>Verifies that fallback warnings do not log exceptions or password fragments.</summary>
[Fact]
public async Task FallbackWarning_does_not_log_full_exception_object_or_password_fragment()
{
@@ -233,6 +241,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
warning.RenderedMessage.ShouldNotContain("User Id=", Case.Insensitive);
}
/// <summary>Verifies that caller cancellation propagates rather than falling back.</summary>
[Fact]
public async Task CallerCancellation_Propagates_NotFallback()
{
@@ -267,22 +276,45 @@ public sealed class ResilientConfigReaderTests : IDisposable
}
}
/// <summary>Represents a captured log record for testing.</summary>
internal sealed record LogRecord(LogLevel LogLevel, string RenderedMessage, Exception? Exception);
/// <summary>Captures log records for assertion in tests.</summary>
internal sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the list of captured log records.</summary>
public List<LogRecord> Records { get; } = new();
/// <summary>Begins a scope (no-op for testing).</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state.</param>
/// <returns>A disposable scope handle.</returns>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Returns true to enable all log levels.</summary>
/// <param name="logLevel">The log level to check.</param>
/// <returns>True to indicate the log level is enabled.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Logs a message by capturing it.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event identifier.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The exception, if any.</param>
/// <param name="formatter">Function to format the log message.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Records.Add(new LogRecord(logLevel, formatter(state, exception), exception));
}
/// <summary>No-op scope for testing.</summary>
private sealed class NullScope : IDisposable
{
/// <summary>Gets the singleton instance.</summary>
public static readonly NullScope Instance = new();
/// <summary>Disposes the scope (no-op).</summary>
public void Dispose() { }
}
}
@@ -290,12 +322,14 @@ internal sealed class CapturingLogger<T> : ILogger<T>
[Trait("Category", "Unit")]
public sealed class StaleConfigFlagTests
{
/// <summary>Verifies that default state is fresh.</summary>
[Fact]
public void Default_IsFresh()
{
new StaleConfigFlag().IsStale.ShouldBeFalse();
}
/// <summary>Verifies that stale and fresh states toggle correctly.</summary>
[Fact]
public void MarkStale_ThenFresh_Toggles()
{
@@ -306,6 +340,7 @@ public sealed class StaleConfigFlagTests
flag.IsStale.ShouldBeFalse();
}
/// <summary>Verifies that concurrent writes converge to the final state.</summary>
[Fact]
public void ConcurrentWrites_Converge()
{
@@ -16,9 +16,12 @@ public sealed class SchemaComplianceFixture : IDisposable
private const string DefaultServer = "10.100.0.35,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
/// <summary>Gets the name of the test database.</summary>
public string DatabaseName { get; }
/// <summary>Gets the connection string for the test database.</summary>
public string ConnectionString { get; }
/// <summary>Initializes a new instance of the SchemaComplianceFixture class.</summary>
public SchemaComplianceFixture()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
@@ -36,6 +39,8 @@ public sealed class SchemaComplianceFixture : IDisposable
ctx.Database.Migrate();
}
/// <summary>Opens a new SQL connection to the test database.</summary>
/// <returns>An open SQL connection.</returns>
public SqlConnection OpenConnection()
{
var conn = new SqlConnection(ConnectionString);
@@ -43,6 +48,7 @@ public sealed class SchemaComplianceFixture : IDisposable
return conn;
}
/// <summary>Disposes the fixture and drops the test database.</summary>
public void Dispose()
{
var masterConnection =
@@ -16,8 +16,11 @@ public sealed class SchemaComplianceTests
{
private readonly SchemaComplianceFixture _fixture;
/// <summary>Initializes a new instance of the <see cref="SchemaComplianceTests"/> class.</summary>
/// <param name="fixture">The database schema compliance fixture.</param>
public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture;
/// <summary>Verifies that all expected tables exist in the schema.</summary>
[Fact]
public void All_expected_tables_exist()
{
@@ -47,6 +50,7 @@ SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;
actual.Count.ShouldBe(expected.Length);
}
/// <summary>Verifies that filtered unique indexes match the schema specification.</summary>
[Fact]
public void Filtered_unique_indexes_match_schema_spec()
{
@@ -73,6 +77,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;",
}
}
/// <summary>Verifies that check constraints match the schema specification.</summary>
[Fact]
public void Check_constraints_match_schema_spec()
{
@@ -94,6 +99,7 @@ WHERE i.is_unique = 1 AND i.has_filter = 1;",
actual.ShouldContain(ck, $"missing CHECK constraint: {ck}");
}
/// <summary>Verifies that JSON check constraints use the ISJSON function.</summary>
[Fact]
public void Json_check_constraints_use_IsJson_function()
{
@@ -110,6 +116,7 @@ WHERE cc.name LIKE 'CK_%_IsJson';",
$"{name} definition does not call ISJSON: {definition}");
}
/// <summary>Verifies that the Deployment Status column exists.</summary>
[Fact]
public void Deployment_Status_column_exists()
{
@@ -125,6 +132,7 @@ WHERE c.TABLE_NAME = 'Deployment' AND c.COLUMN_NAME = 'Status';",
rows.Count.ShouldBe(1);
}
/// <summary>Verifies that Equipment carries OPC 4.0 10 identity fields.</summary>
[Fact]
public void Equipment_carries_Opc40010_identity_fields()
{
@@ -140,6 +148,7 @@ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
}
/// <summary>Verifies that Namespace has at least one unique index.</summary>
[Fact]
public void Namespace_has_some_unique_index()
{