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

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

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -18,8 +18,8 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// <summary>
/// FU-B — integration coverage for the post-flush second-pass rewire in
/// <c>BundleImporter.ApplyAsync</c>: composition edges (<c>#39</c>) and
/// alarm-script FKs (remainder of <c>#37</c>). All three scenarios drive the
/// <c>BundleImporter.ApplyAsync</c>: composition edges and
/// alarm-script FKs. All three scenarios drive the
/// full export → load → apply pipeline so the wire-level DTO carries the
/// name-keyed references the importer is expected to resolve.
/// </summary>
@@ -27,6 +27,7 @@ public sealed class CompositionImportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with an in-memory database and the Transport services.</summary>
public CompositionImportTests()
{
var services = new ServiceCollection();
@@ -43,7 +44,7 @@ public sealed class CompositionImportTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
@@ -53,6 +54,7 @@ public sealed class CompositionImportTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>
@@ -97,6 +99,8 @@ public sealed class CompositionImportTests : IDisposable
await ctx.SaveChangesAsync();
}
/// <summary>Verifies that composition edges are restored on import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Composition_edges_are_restored_on_import()
{
@@ -163,6 +167,8 @@ public sealed class CompositionImportTests : IDisposable
}
}
/// <summary>Verifies that a composition referencing a skipped template emits an unresolved audit entry and skips the edge.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Composition_referencing_skipped_template_emits_unresolved_audit_and_skips_edge()
{
@@ -241,6 +247,8 @@ public sealed class CompositionImportTests : IDisposable
}
}
/// <summary>Verifies that the on-trigger script alarm link is restored on import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnTriggerScript_alarm_link_is_restored_on_import()
{
@@ -25,6 +25,7 @@ public sealed class ConflictResolutionTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test with an in-memory configuration database and the Transport services.</summary>
public ConflictResolutionTests()
{
var services = new ServiceCollection();
@@ -41,7 +42,7 @@ public sealed class ConflictResolutionTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
@@ -51,6 +52,7 @@ public sealed class ConflictResolutionTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>
@@ -94,6 +96,8 @@ public sealed class ConflictResolutionTests : IDisposable
return session.SessionId;
}
/// <summary>Verifies that the Overwrite resolution replaces the existing template's description.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Overwrite_replaces_existing_template_description()
{
@@ -140,6 +144,8 @@ public sealed class ConflictResolutionTests : IDisposable
Assert.Equal(1, result.Overwritten);
}
/// <summary>Verifies that the Skip resolution leaves the existing template unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Skip_leaves_existing_template_unchanged()
{
@@ -186,6 +192,8 @@ public sealed class ConflictResolutionTests : IDisposable
Assert.Equal(0, result.Overwritten);
}
/// <summary>Verifies that the Rename resolution creates a new template alongside the existing one.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rename_creates_new_template_alongside_existing()
{
@@ -35,6 +35,7 @@ public sealed class BundleExporterTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with an in-memory database and the exporter under test.</summary>
public BundleExporterTests()
{
var services = new ServiceCollection();
@@ -64,7 +65,7 @@ public sealed class BundleExporterTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
// Repositories the resolver pulls from. M8 (B4): the resolver now injects
// Repositories the resolver pulls from. The resolver now injects
// ISiteRepository to walk the site/data-connection/instance closure, so it
// must be registered or the BuildServiceProvider-time graph resolution for
// DependencyResolver fails.
@@ -87,8 +88,11 @@ public sealed class BundleExporterTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>Verifies that exporting writes an audit event and returns a valid bundle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle()
{
@@ -214,6 +218,8 @@ public sealed class BundleExporterTests : IDisposable
}
}
/// <summary>Verifies that exporting emits the selected SMS config with its secret and the summary count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_emits_selected_sms_config_with_secret_and_summary_count()
{
@@ -286,6 +292,8 @@ public sealed class BundleExporterTests : IDisposable
Assert.Equal("super-secret-token", token);
}
/// <summary>Verifies that exporting with a passphrase produces an encrypted bundle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_with_passphrase_produces_encrypted_bundle()
{
@@ -369,6 +377,8 @@ public sealed class BundleExporterTests : IDisposable
}
}
/// <summary>Verifies that exporting with a site selection packs the sites, data connections and instances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_with_site_selection_packs_sites_dataconnections_and_instances()
{
@@ -464,7 +474,7 @@ public sealed class BundleExporterTests : IDisposable
Assert.Equal("NORTH-01.Pump1", content.Instances[0].UniqueName);
Assert.Equal("PumpStation", content.Instances[0].TemplateName);
// The instance carries its binding, and the manifest dep-edge resolves the
// owning site by identifier (I1) — Site:NORTH-01, never Site:<rawId>.
// owning site by identifier (I1) — the site's identifier string, never the raw id.
Assert.Single(content.Instances[0].ConnectionBindings);
var instanceEntry = Assert.Single(
manifest.Contents,
@@ -473,6 +483,8 @@ public sealed class BundleExporterTests : IDisposable
Assert.DoesNotContain(instanceEntry.DependsOn, d => d == $"Site:{siteId}");
}
/// <summary>Verifies that a directly selected instance records its owning site by identifier.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_directly_selected_instance_records_owning_site_by_identifier()
{
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// Integration tests for Area-by-name transport (Task 7): a source instance
/// Integration tests for Area-by-name transport: a source instance
/// assigned to an <see cref="Area"/> under its site must carry that area's NAME
/// through the exported bundle so the importer's <c>ResolveOrCreateAreaIdAsync</c>
/// machinery can resolve-or-create the equivalent area under the target site. This
@@ -36,6 +36,7 @@ public sealed class AreaTransportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Builds the service provider with an in-memory database and the Transport services.</summary>
public AreaTransportTests()
{
var services = new ServiceCollection();
@@ -60,12 +61,15 @@ public sealed class AreaTransportTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>Verifies that an instance's area travels by name and resolves under the target site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Instance_Area_travels_by_name_and_resolves_under_target_site()
{
// ---- Seed a site + instance assigned to an Area "Line-1" ----
// ---- Seed a site + instance assigned to an Area "Line 1" ----
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
@@ -161,7 +165,7 @@ public sealed class AreaTransportTests : IDisposable
}
// ---- Assert: the imported instance's AreaId resolves to an Area named
// "Line-1" under the target site (NOT null). ----
// "Line 1" under the target site (NOT null). ----
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
@@ -48,6 +48,7 @@ public sealed class BundleImporterApplyTests : IDisposable
private readonly ServiceProvider _provider;
private readonly RecordingScriptArtifactChangeBus _artifactBus = new();
/// <summary>Initializes a new instance of the <c>BundleImporterApplyTests</c> class.</summary>
public BundleImporterApplyTests()
{
var services = new ServiceCollection();
@@ -82,11 +83,11 @@ public sealed class BundleImporterApplyTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
// M9-T32: FlatteningPipeline gained an ISharedSchemaRepository constructor
// FlatteningPipeline gained an ISharedSchemaRepository constructor
// dependency for schema-library $ref resolution; register the real repo so
// AddDeploymentManager()'s IFlatteningPipeline → FlatteningPipeline activates.
// SharedSchemaRepository only needs ScadaBridgeDbContext, which is already
@@ -95,11 +96,11 @@ public sealed class BundleImporterApplyTests : IDisposable
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
// #05-T14: a recording bus so the post-commit ScriptArtifactsChanged
// A recording bus so the post-commit ScriptArtifactsChanged
// publish is observable. BundleImporter takes IScriptArtifactChangeBus?
// optionally, so this registration flows in via DI activation.
services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus);
// #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
// The stale-instance probe is implemented in DeploymentManager
// and the flatten/hash primitives in TemplateEngine — register both so
// BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
// compute StaleInstanceIds against genuine revision hashes.
@@ -109,6 +110,7 @@ public sealed class BundleImporterApplyTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Disposes the underlying service provider.</summary>
public void Dispose() => _provider.Dispose();
// ---- helpers ----
@@ -168,6 +170,8 @@ public sealed class BundleImporterApplyTests : IDisposable
// ---- tests ----
/// <summary>Verifies that applying a bundle adds new artifacts in a single transaction.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_adds_new_artifacts_in_single_transaction()
{
@@ -210,6 +214,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.NotEqual(Guid.Empty, result.BundleImportId);
}
/// <summary>Verifies that applying a bundle overwrites an existing artifact when the resolution is Overwrite.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite()
{
@@ -251,6 +257,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Equal(1, result.Overwritten);
}
/// <summary>Verifies that applying a bundle leaves an existing artifact untouched when the resolution is Skip.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip()
{
@@ -287,6 +295,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Equal(0, result.Overwritten);
}
/// <summary>Verifies that applying a bundle imports an artifact under a new name when the resolution is Rename.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename()
{
@@ -321,13 +331,15 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Equal(1, result.Renamed);
}
/// <summary>Verifies that applying a bundle rolls back all changes when semantic validation fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
{
// Arrange: seed an ApiMethod whose script body calls MissingHelper().
// No SharedScript by that name exists in source or (after wipe) in the
// target. ApiMethod name-resolution findings are HARD errors (unlike
// template-script findings, which are advisory as of #05-T19), so this
// template-script findings, which are advisory), so this
// must reject the apply and roll everything back.
await using (var scope = _provider.CreateAsyncScope())
{
@@ -364,6 +376,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Null(sessionStore.Get(sessionId));
}
/// <summary>Verifies that applying a bundle removes the import session on the success path as well.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_removes_session_on_success_path_too()
{
@@ -392,6 +406,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Null(sessionStore.Get(sessionId));
}
/// <summary>Verifies that applying a bundle stamps the bundle import id on every audit row it emits.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row()
{
@@ -444,6 +460,8 @@ public sealed class BundleImporterApplyTests : IDisposable
}
}
/// <summary>Verifies that applying a bundle writes the BundleImported summary row inside the transaction.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction()
{
@@ -482,6 +500,8 @@ public sealed class BundleImporterApplyTests : IDisposable
}
}
/// <summary>Verifies that a failed apply writes the BundleImportFailed row outside the rolled-back transaction.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction()
{
@@ -521,6 +541,8 @@ public sealed class BundleImporterApplyTests : IDisposable
}
}
/// <summary>Verifies that overwriting a template synchronizes its attributes, alarms and scripts to the bundle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle()
{
@@ -664,6 +686,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Equal(1, result.Overwritten);
}
/// <summary>Verifies that overwriting an external system synchronizes its methods to the bundle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle()
{
@@ -789,10 +813,12 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Equal(1, result.Overwritten);
}
/// <summary>Verifies that adding a new external system carries over its timeout seconds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_adds_new_external_system_with_TimeoutSeconds()
{
// arch-review 2026-08-19 P7-1 (row-27 shape): TimeoutSeconds is the
// TimeoutSeconds shape: TimeoutSeconds is the
// per-system HTTP timeout ExternalSystemClient reads at call time — it must
// survive Add, not just the retry policy (T-002 above covers the sibling
// Methods-drop regression on Overwrite; this is the Add-path fidelity gap).
@@ -831,6 +857,8 @@ public sealed class BundleImporterApplyTests : IDisposable
}
}
/// <summary>Verifies that overwriting an external system restores its timeout seconds to the bundle value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_Overwrite_restores_TimeoutSeconds_to_bundle_value()
{
@@ -876,9 +904,9 @@ public sealed class BundleImporterApplyTests : IDisposable
}
// ─────────────────────────────────────────────────────────────────────
// arch-review 2026-08-19 P7-1: old-bundle semantics for TimeoutSeconds. Hand-packs
// Old-bundle semantics for TimeoutSeconds. Hand-packs
// a BundleContentDto with an ExternalSystemDto whose TimeoutSeconds is left at its
// default (null) — the same in-memory shape a pre-P7-1 bundle deserializes to,
// default (null) — the same in-memory shape a pre-TimeoutSeconds bundle deserializes to,
// since the field is simply absent from that JSON text.
// ─────────────────────────────────────────────────────────────────────
private async Task<Guid> PackAndLoadExternalSystemBundleAsync(params ExternalSystemDto[] externalSystems)
@@ -916,6 +944,8 @@ public sealed class BundleImporterApplyTests : IDisposable
return session.SessionId;
}
/// <summary>Verifies that adding from an old bundle without timeout seconds uses the entity default.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_Add_with_old_bundle_missing_TimeoutSeconds_uses_entity_default()
{
@@ -940,6 +970,8 @@ public sealed class BundleImporterApplyTests : IDisposable
}
}
/// <summary>Verifies that overwriting from an old bundle without timeout seconds preserves the target's value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_Overwrite_with_old_bundle_missing_TimeoutSeconds_preserves_target_value()
{
@@ -1022,6 +1054,8 @@ public sealed class BundleImporterApplyTests : IDisposable
return session.SessionId;
}
/// <summary>Verifies that applying a bundle creates a new SMS config with its secret decrypted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_creates_new_sms_config_with_decrypted_secret()
{
@@ -1060,6 +1094,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Equal(1, result.Added);
}
/// <summary>Verifies that applying a bundle overwrites an existing SMS config matched by account SID.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_overwrites_existing_sms_config_matched_by_account_sid()
{
@@ -1119,6 +1155,8 @@ public sealed class BundleImporterApplyTests : IDisposable
// NOT fail to parse, must NOT create any ApiKey rows, and must surface the
// ignored-key count on the result (so the operator knows to re-issue keys).
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that applying a bundle ignores legacy API keys without failing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_ignores_legacy_api_keys_in_bundle_without_failing()
{
@@ -1214,6 +1252,8 @@ public sealed class BundleImporterApplyTests : IDisposable
// surfaces no ApiKey/key preview items, and DOES surface the ApiMethod
// preview item.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that previewing a legacy bundle does not surface API key items.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_on_legacy_bundle_does_not_surface_key_items()
{
@@ -1290,7 +1330,7 @@ public sealed class BundleImporterApplyTests : IDisposable
}
// ─────────────────────────────────────────────────────────────────────
// #16 (M8 D2): StaleInstanceIds enumeration. Overwriting a template changes
// StaleInstanceIds enumeration. Overwriting a template changes
// its flattened-config hash, so deployed instances of that template drift
// from their DeployedConfigSnapshot.RevisionHash and must surface as stale
// in the import result. NotDeployed (incl. freshly-imported) instances are
@@ -1356,6 +1396,8 @@ public sealed class BundleImporterApplyTests : IDisposable
return site.Id;
}
/// <summary>Verifies that overwriting a template marks its deployed instance as stale.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale()
{
@@ -1405,6 +1447,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
}
/// <summary>Verifies that overwriting a template does not mark instances of an unchanged template as stale.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale()
{
@@ -1461,6 +1505,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
}
/// <summary>Verifies that a freshly imported NotDeployed instance is never reported as stale.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale()
{
@@ -1545,6 +1591,7 @@ public sealed class BundleImporterApplyTests : IDisposable
/// depends entirely on a FRESH hash rather than on the leftover drift.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_second_overwrite_of_same_template_still_marks_instance_stale()
{
@@ -1587,7 +1634,7 @@ public sealed class BundleImporterApplyTests : IDisposable
}
var instanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
// Import #1 — Overwrite to shape A. Drifts the instance off its snapshot.
// First import — Overwrite to shape A. Drifts the instance off its snapshot.
ImportResult first;
await using (var scope = _provider.CreateAsyncScope())
{
@@ -1598,11 +1645,11 @@ public sealed class BundleImporterApplyTests : IDisposable
}
Assert.Contains(instanceId, first.StaleInstanceIds);
// The operator redeploys: the snapshot now matches the post-import-#1 config,
// so the instance is genuinely up to date going into import #2.
// The operator redeploys: the snapshot now matches the post-first-import config,
// so the instance is genuinely up to date going into the second import.
await RefreshDeployedSnapshotAsync(instanceId);
// Import #2 — Overwrite the SAME template to shape B. The instance drifts
// Second import — Overwrite the SAME template to shape B. The instance drifts
// again and must be reported again.
ImportResult second;
await using (var scope = _provider.CreateAsyncScope())
@@ -1639,8 +1686,10 @@ public sealed class BundleImporterApplyTests : IDisposable
await ctx.SaveChangesAsync();
}
// ============ #05-T14: post-commit ScriptArtifactsChanged publish ============
// ============ Post-commit ScriptArtifactsChanged publish ============
/// <summary>Verifies that applying a bundle publishes a script-artifacts-changed notification per kind after commit.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit()
{
@@ -1703,6 +1752,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
}
/// <summary>Verifies that applying a bundle publishes script-artifacts-changed notifications for Add resolutions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
{
@@ -1758,6 +1809,8 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
}
/// <summary>Verifies that a failed apply publishes no notifications.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_failed_apply_publishes_nothing()
{
@@ -1809,10 +1862,13 @@ public sealed class BundleImporterApplyTests : IDisposable
/// <summary>Records every published notification for assertion.</summary>
private sealed class RecordingScriptArtifactChangeBus : IScriptArtifactChangeBus
{
/// <summary>Gets the notifications received by this bus.</summary>
public List<ScriptArtifactsChanged> Received { get; } = new();
/// <inheritdoc />
public void Publish(ScriptArtifactsChanged notification) => Received.Add(notification);
/// <inheritdoc />
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler) =>
throw new NotSupportedException("The recording bus does not support subscriptions.");
}
@@ -33,6 +33,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes a new instance of the <c>BundleImporterPreviewTests</c> class.</summary>
public BundleImporterPreviewTests()
{
var services = new ServiceCollection();
@@ -52,7 +53,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
@@ -62,6 +63,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose() => _provider.Dispose();
private async Task<Stream> ExportTemplatesAsync()
@@ -224,6 +226,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: bindings ?? Array.Empty<InstanceConnectionBindingDto>());
/// <summary>Verifies that PreviewAsync classifies an artifact as identical when its fields match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
{
@@ -255,6 +259,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Null(pumpItem.FieldDiffJson);
}
/// <summary>Verifies that PreviewAsync classifies an artifact as modified and reports the field diff.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff()
{
@@ -299,6 +305,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal);
}
/// <summary>Verifies that PreviewAsync classifies an artifact as new when it is absent from the target.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target()
{
@@ -338,10 +346,12 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Null(pumpItem.FieldDiffJson);
}
/// <summary>Verifies that PreviewAsync classifies an unchanged structured template as identical.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_unchanged_structured_template_classifies_Identical()
{
// #05-T23 end-to-end: a template with a base (ParentTemplateId) and a
// End-to-end: a template with a base (ParentTemplateId) and a
// composition, re-imported against an UNCHANGED target, must classify
// Identical. Before T23, PreviewAsync compared "<id:N>" placeholders for
// BaseTemplateName / composition target against the bundle's real names,
@@ -383,6 +393,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
}
/// <summary>Verifies that PreviewAsync emits a warning when a template script dependency is missing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_emits_Warning_when_template_script_dependency_missing()
{
@@ -392,7 +404,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
// selection that only pulls the template — the bundle won't carry
// MissingHelper (it doesn't exist anywhere) so the preview must flag it.
//
// Under #05-T19 a TEMPLATE-script name-resolution finding is ADVISORY
// A TEMPLATE-script name-resolution finding is ADVISORY
// (the design-time deploy gate re-validates it), so the preview surfaces
// it as ConflictKind.Warning — NOT a Blocker.
await using (var scope = _provider.CreateAsyncScope())
@@ -431,10 +443,12 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.DoesNotContain(preview.Items, i => i.Name == "HelperFn");
}
/// <summary>Verifies that PreviewAsync emits a blocker when an API method dependency is missing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_emits_Blocker_when_ApiMethod_dependency_missing()
{
// #05-T19 severity split: an ApiMethod script referencing a missing
// Severity split: an ApiMethod script referencing a missing
// dependency has no downstream deploy gate, so the preview must surface
// it as a hard Blocker (parity with the apply-time SemanticValidationException).
await using (var scope = _provider.CreateAsyncScope())
@@ -479,10 +493,12 @@ public sealed class BundleImporterPreviewTests : IDisposable
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
}
/// <summary>Verifies that PreviewAsync emits a blocker when a template script uses a forbidden API.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_emits_Blocker_when_template_script_has_forbidden_api()
{
// #05-T20 — the script trust gate runs in preview too, so a forbidden-API
// The script trust gate runs in preview too, so a forbidden-API
// script surfaces as a Blocker row (parity with the apply-time
// SemanticValidationException). Trust violations block regardless of kind.
await using (var scope = _provider.CreateAsyncScope())
@@ -511,6 +527,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
&& i.BlockerReason.Contains("Process", StringComparison.Ordinal));
}
/// <summary>Verifies that PreviewAsync does not flag OPC UA tag paths in a data source reference as blockers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers()
{
@@ -550,6 +568,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
i.Kind == ConflictKind.Blocker && i.Name == "Tank");
}
/// <summary>Verifies that PreviewAsync does not flag standard-library or runtime member accesses as blockers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers()
{
@@ -599,10 +619,12 @@ public sealed class BundleImporterPreviewTests : IDisposable
}
}
/// <summary>Verifies that PreviewAsync diffs each of several templates with children correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_multiple_templates_with_children_diffs_each_correctly()
{
// Transport-008 regression: PreviewAsync previously fetched each matching
// Regression: PreviewAsync previously fetched each matching
// template's children via a per-name GetTemplateWithChildrenAsync call
// (N+1). The bulk variant returns every match in a single query — this
// test seeds three templates with distinct child collections and asserts
@@ -647,8 +669,10 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Equal(ConflictKind.Identical, tankItem.Kind);
}
// ---- M8 C2: site/connection/instance preview + required-mapping detection + blockers ----
// ---- Site/connection/instance preview + required-mapping detection + blockers ----
/// <summary>Verifies that PreviewAsync into a fresh target surfaces the required mappings with a null auto-match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_into_fresh_target_surfaces_required_mappings_with_null_auto_match()
{
@@ -694,6 +718,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary" && i.Kind == ConflictKind.New);
}
/// <summary>Verifies that PreviewAsync into a populated target auto-matches the site and connection.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_into_populated_target_auto_matches_site_and_connection()
{
@@ -730,10 +756,12 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
}
/// <summary>Verifies that PreviewAsync emits two distinct site-qualified items for two sites sharing a connection name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_two_sites_same_connection_name_emit_two_distinct_site_qualified_items()
{
// C2: a bundle with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must surface
// A bundle with two sites (plant 1 and plant 2) each holding OpcUaPrimary must surface
// TWO distinct DataConnection preview items, each site-qualified
// ({site}/{name}) — NOT a single collapsed "OpcUaPrimary" item. This is what
// lets the operator (and the apply path) resolve each site's connection
@@ -782,6 +810,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.DoesNotContain(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
}
/// <summary>Verifies that PreviewAsync for a modified instance against a hydrated target shows a child diff rather than everything as added.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
{
@@ -823,6 +853,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
Assert.DoesNotContain("\"ConnectionBindings\"", instItem.FieldDiffJson!, StringComparison.Ordinal);
}
/// <summary>Verifies that PreviewAsync emits a blocker for an instance whose template is missing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_instance_with_missing_template_emits_blocker()
{
@@ -854,6 +886,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
&& i.BlockerReason.Contains("NoSuchTemplate", StringComparison.Ordinal));
}
/// <summary>Verifies that PreviewAsync emits a blocker when a referenced connection is absent from both the bundle and the target.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewAsync_referenced_connection_absent_from_bundle_and_target_emits_blocker()
{
@@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// Regression guard for Gitea #28: <see cref="BundleImporter.ApplyAsync"/> must
/// Regression guard: <see cref="BundleImporter.ApplyAsync"/> must
/// run its multi-<c>SaveChanges</c> apply through the context's execution
/// strategy, because the production central <c>ScadaBridgeDbContext</c> is
/// configured with <c>EnableRetryOnFailure</c> and
@@ -50,6 +50,7 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable
private readonly ServiceProvider _provider;
private readonly DbConnection _sharedConnection;
/// <summary>Initializes a new instance of the <c>BundleImporterRetryingStrategyTests</c> class.</summary>
public BundleImporterRetryingStrategyTests()
{
var services = new ServiceCollection();
@@ -104,12 +105,15 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable
ctx.Database.EnsureCreated();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_provider.Dispose();
_sharedConnection.Dispose();
}
/// <summary>Verifies that applying a bundle succeeds under a retrying execution strategy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_succeeds_under_a_retrying_execution_strategy()
{
@@ -211,11 +215,14 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable
/// </summary>
private sealed class TestRetryingExecutionStrategy : ExecutionStrategy
{
/// <summary>Initializes a new instance of the <c>TestRetryingExecutionStrategy</c> class.</summary>
/// <param name="dependencies">The execution strategy dependencies.</param>
public TestRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: base(dependencies, maxRetryCount: 3, maxRetryDelay: TimeSpan.FromMilliseconds(1))
{
}
/// <inheritdoc />
protected override bool ShouldRetryOn(Exception exception) => false;
}
}
@@ -50,6 +50,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
private readonly DbConnection _sharedConnection;
private readonly ThrowingRollbackInterceptor _interceptor = new();
/// <summary>Initializes the in-memory SQLite database and service provider used by the tests.</summary>
public BundleImporterRollbackFailureTests()
{
var services = new ServiceCollection();
@@ -87,7 +88,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
@@ -102,12 +103,15 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
ctx.Database.EnsureCreated();
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_provider.Dispose();
_sharedConnection.Dispose();
}
/// <summary>Verifies that applying a bundle still writes a BundleImportFailed audit entry when the transaction rollback itself throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_writes_BundleImportFailed_even_when_RollbackAsync_throws()
{
@@ -225,8 +229,10 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
{
public const string RollbackErrorMarker = "simulated rollback failure";
/// <summary>Gets or sets whether the interceptor throws when a transaction rolls back.</summary>
public bool ThrowOnRollback { get; set; }
/// <inheritdoc />
public override ValueTask<InterceptionResult> TransactionRollingBackAsync(
DbTransaction transaction,
TransactionEventData eventData,
@@ -240,6 +246,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
return base.TransactionRollingBackAsync(transaction, eventData, result, cancellationToken);
}
/// <inheritdoc />
public override InterceptionResult TransactionRollingBack(
DbTransaction transaction,
TransactionEventData eventData,
@@ -263,6 +270,9 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
/// </summary>
internal sealed class SqliteCompatibleScadaBridgeDbContext : ScadaBridgeDbContext
{
/// <summary>Initializes the context with the given options and data protection provider.</summary>
/// <param name="options">The DbContext options.</param>
/// <param name="dataProtectionProvider">The data protection provider used for encrypted columns.</param>
public SqliteCompatibleScadaBridgeDbContext(
DbContextOptions<ScadaBridgeDbContext> options,
IDataProtectionProvider dataProtectionProvider)
@@ -270,6 +280,7 @@ internal sealed class SqliteCompatibleScadaBridgeDbContext : ScadaBridgeDbContex
{
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -33,6 +33,7 @@ public sealed class CreateMissingSiteRelationalTests : IDisposable
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with a temporary database.</summary>
public CreateMissingSiteRelationalTests()
{
// One open in-memory SQLite connection for the fixture's lifetime — the
@@ -69,12 +70,15 @@ public sealed class CreateMissingSiteRelationalTests : IDisposable
scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.EnsureCreated();
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
/// <summary>Verifies that importing with create-missing for a site and connection wires the materialized site id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_create_missing_site_and_connection_wires_the_materialised_site_id()
{
@@ -23,6 +23,7 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
/// <summary>Initializes a new instance of the <c>FleetWideUnlockThrottleTests</c> class.</summary>
public FleetWideUnlockThrottleTests()
{
// One open in-memory SQLite connection for the fixture's lifetime — the database
@@ -40,6 +41,7 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.EnsureCreated();
}
/// <summary>Disposes the service provider and the shared connection.</summary>
public void Dispose()
{
_provider.Dispose();
@@ -61,10 +63,12 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
_provider.GetRequiredService<IServiceScopeFactory>(), clock),
logger: null);
/// <summary>Verifies that two nodes sharing one store enforce a single combined throttle window.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TwoNodesSharingOneStore_EnforceASingleCombinedWindow()
{
// The headline row-52 contract: attempts spread across both central nodes count
// The headline contract: attempts spread across both central nodes count
// against ONE budget. Before this, five attempts per node were both allowed.
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
var nodeA = NewNode(clock);
@@ -81,6 +85,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.False(await nodeB.TryRegisterAttemptAsync("10.0.0.1", 10));
}
/// <summary>Verifies that denied attempts do not consume any of the throttle budget.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeniedAttempts_BurnNoBudget()
{
@@ -108,6 +114,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.Equal(3, recorded);
}
/// <summary>Verifies that the throttle window survives a restart.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TheWindowIsRestartDurable()
{
@@ -125,6 +133,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.False(await afterRestart.TryRegisterAttemptAsync("10.0.0.1", 3));
}
/// <summary>Verifies that the throttle window slides so an aged-out budget is released.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TheWindowSlides_SoAnAgedOutBudgetIsReleased()
{
@@ -142,6 +152,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 3));
}
/// <summary>Verifies that throttle buckets are isolated per client.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BucketsAreIsolatedPerClient()
{
@@ -156,6 +168,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.True(await node.TryRegisterAttemptAsync("<unattributed>", 1));
}
/// <summary>Verifies that a store failure fails open and does not block the unlock.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreFailure_FailsOpen_AndDoesNotBlockTheUnlock()
{
@@ -176,6 +190,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
}
}
/// <summary>Verifies that caller cancellation is not swallowed by the fail-open path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CallerCancellation_IsNotSwallowedByTheFailOpenPath()
{
@@ -197,10 +213,12 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
() => limiter.TryRegisterAttemptAsync("10.0.0.1", 10, cts.Token));
}
/// <summary>Verifies that without a shared window the limiter still enforces a process-local cap.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WithoutASharedWindow_TheLimiterStillEnforcesAProcessLocalCap()
{
// Hosts that register Transport without a shared window keep the pre-row-52
// Hosts that register Transport without a shared window keep the pre-shared-window
// behaviour rather than losing the cap entirely.
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
var limiter = new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow);
@@ -210,6 +228,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.False(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1));
}
/// <summary>Verifies that aged rows are pruned opportunistically once the prune interval has elapsed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AgedRowsArePrunedOpportunistically_OnceThePruneIntervalHasElapsed()
{
@@ -233,6 +253,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.Equal("10.0.0.2", Assert.Single(remaining).ClientKey);
}
/// <summary>Verifies that recent rows survive a prune pass.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RecentRowsSurviveAPrunePass()
{
@@ -252,6 +274,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.Equal(2, await ctx.BundleUnlockAttempts.AsNoTracking().CountAsync());
}
/// <summary>Verifies that the scheduled prune clears aged rows even when there is no unlock traffic.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScheduledPrune_ClearsAgedRows_WithNoUnlockTrafficAtAll()
{
@@ -275,6 +299,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.Empty(await ctx.BundleUnlockAttempts.AsNoTracking().ToListAsync());
}
/// <summary>Verifies that the scheduled prune respects the retention horizon and the cadence claim.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScheduledPrune_RespectsTheRetentionHorizonAndTheCadenceClaim()
{
@@ -300,6 +326,8 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
Assert.Equal(1, await ctx.BundleUnlockAttempts.AsNoTracking().CountAsync());
}
/// <summary>Verifies that the scheduled prune does not throw when the store is unavailable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScheduledPrune_DoesNotThrow_WhenTheStoreIsUnavailable()
{
@@ -320,8 +348,13 @@ public sealed class FleetWideUnlockThrottleTests : IDisposable
private sealed class TestClock : TimeProvider
{
private DateTimeOffset _now;
/// <summary>Initializes a new instance of the <c>TestClock</c> class.</summary>
/// <param name="start">The initial time of the clock.</param>
public TestClock(DateTimeOffset start) { _now = start; }
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the test clock forward by the given amount.</summary>
/// <param name="by">The amount to advance the clock by.</param>
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}
@@ -28,6 +28,7 @@ public sealed class InheritanceImportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes a new instance of the <see cref="InheritanceImportTests"/> class.</summary>
public InheritanceImportTests()
{
var services = new ServiceCollection();
@@ -52,6 +53,7 @@ public sealed class InheritanceImportTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _provider.Dispose();
private async Task<byte[]> ExportAllTemplatesAsync()
@@ -110,6 +112,8 @@ public sealed class InheritanceImportTests : IDisposable
return session.SessionId;
}
/// <summary>Verifies that importing a derived template wires its parent template id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_DerivedTemplate_WiresParentTemplateId()
{
@@ -137,6 +141,8 @@ public sealed class InheritanceImportTests : IDisposable
}
}
/// <summary>Verifies that an overwrite import updates the parent edge and honours a renamed base.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_Overwrite_UpdatesParentEdge_AndRenamedBaseIsHonoured()
{
@@ -167,6 +173,8 @@ public sealed class InheritanceImportTests : IDisposable
}
}
/// <summary>Verifies that importing a derived template whose base was skipped leaves the parent null and audits it as unresolved.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_DerivedWithSkippedBase_LeavesNullAndAuditsUnresolved()
{
@@ -218,6 +226,8 @@ public sealed class InheritanceImportTests : IDisposable
await ctx.SaveChangesAsync();
}
/// <summary>Verifies that an overwrite import that closes an inheritance cycle throws and rolls back.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_Overwrite_ThatClosesInheritanceCycle_Throws_AndRollsBack()
{
@@ -254,6 +264,8 @@ public sealed class InheritanceImportTests : IDisposable
}
}
/// <summary>Verifies that an overwrite import that closes a composition cycle throws and rolls back.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_Overwrite_ThatClosesCompositionCycle_Throws_AndRollsBack()
{
@@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// #05-T5: a template's <see cref="TemplateNativeAlarmSource"/> collection must
/// A template's <see cref="TemplateNativeAlarmSource"/> collection must
/// survive an export→import round-trip (Add + Overwrite sync), diff in Preview,
/// and — crucially — carry across environments so an instance-level override of a
/// native alarm source no longer dangles in the target. Before this fix the
@@ -34,6 +34,7 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the in-memory host used by the import tests.</summary>
public NativeAlarmSourceImportTests()
{
var services = new ServiceCollection();
@@ -67,6 +68,7 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Disposes the underlying service provider.</summary>
public void Dispose() => _provider.Dispose();
private async Task<Guid> ExportAllTemplatesAndLoadAsync()
@@ -118,6 +120,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
LockedInDerived = lockedInDerived,
};
/// <summary>Verifies that an Add import carries the template's native alarm sources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_Add_CarriesTemplateNativeAlarmSources()
{
@@ -165,6 +169,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
}
}
/// <summary>Verifies that an Overwrite import with no change emits no native source audit.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_Overwrite_NoChange_EmitsNoNativeSourceAudit()
{
@@ -196,6 +202,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
}
}
/// <summary>Verifies that an Overwrite import synchronizes the native alarm sources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_Overwrite_SyncsNativeAlarmSources()
{
@@ -253,6 +261,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
}
}
/// <summary>Verifies that Preview diffs the native alarm sources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Preview_DiffsNativeAlarmSources()
{
@@ -286,6 +296,8 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
}
}
/// <summary>Verifies that an imported instance override no longer dangles.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_InstanceOverride_NoLongerDangles()
{
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// Integration tests for the M8 D1 site/instance-scoped apply path of
/// Integration tests for the site/instance-scoped apply path of
/// <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.ApplyAsync"/>:
/// resolve-or-create target sites + data connections from a <see cref="BundleNameMap"/>,
/// upsert instances, and rewire every cross-environment FK (connection-binding
@@ -38,6 +38,7 @@ public sealed class SiteInstanceImportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with a service provider backed by an in-memory database.</summary>
public SiteInstanceImportTests()
{
var services = new ServiceCollection();
@@ -68,6 +69,7 @@ public sealed class SiteInstanceImportTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose() => _provider.Dispose();
// ──────────────────────────────────────────────────────────────────────
@@ -246,6 +248,8 @@ public sealed class SiteInstanceImportTests : IDisposable
// CreateNew into a fresh target
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that creating into a fresh target creates the site, connection and instance with rewired foreign keys.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_CreateNew_into_fresh_target_creates_site_connection_instance_with_rewired_FKs()
{
@@ -327,7 +331,7 @@ public sealed class SiteInstanceImportTests : IDisposable
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Skipped);
// No template was overwritten and the imported instance is NotDeployed, so
// the D2 (#16) stale-instance enumeration finds nothing.
// the stale-instance enumeration finds nothing.
Assert.Empty(result.StaleInstanceIds);
}
@@ -335,11 +339,13 @@ public sealed class SiteInstanceImportTests : IDisposable
// MapToExisting into a populated target (FK remap to existing ids)
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that mapping to an existing connection remaps the binding to the existing target connection id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_MapToExisting_remaps_binding_to_existing_target_connection_id()
{
// Source env: plant-1 / OpcUaPrimary. Build a bundle from it, then set up
// a DISTINCT target env that already has plant-1 + OpcUaPrimary with their
// Source env: plant 1 / OpcUaPrimary. Build a bundle from it, then set up
// a DISTINCT target env that already has plant 1 + OpcUaPrimary with their
// OWN surrogate ids (achieved by wiping + reseeding so ids differ from the
// source's). The import must MapToExisting and rebind to the TARGET ids.
await SeedSiteClosureAsync();
@@ -420,6 +426,8 @@ public sealed class SiteInstanceImportTests : IDisposable
// Rename an instance on import
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that applying renames the instance unique name on import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_renames_instance_unique_name_on_import()
{
@@ -467,6 +475,8 @@ public sealed class SiteInstanceImportTests : IDisposable
// Cross-site rebind: source site maps to a DIFFERENTLY-named target site
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that applying maps the source site to a differently named target site and rebinds the connections.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_maps_source_site_to_differently_named_target_site_and_rebinds_connections()
{
@@ -475,7 +485,7 @@ public sealed class SiteInstanceImportTests : IDisposable
await WipeSiteClosureAsync();
// Target env has a DIFFERENTLY-identified site (plant-west) carrying a
// same-named connection. The operator maps plant-1 → plant-west.
// same-named connection. The operator maps plant 1 → plant-west.
int westSiteId;
int westConnId;
await using (var scope = _provider.CreateAsyncScope())
@@ -514,7 +524,7 @@ public sealed class SiteInstanceImportTests : IDisposable
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// No plant-1 site was created — the bundle's site mapped onto plant-west.
// No plant 1 site was created — the bundle's site mapped onto plant-west.
Assert.Equal(0, await ctx.Sites.CountAsync(s => s.SiteIdentifier == "plant-1"));
var inst = await ctx.Instances
@@ -532,6 +542,8 @@ public sealed class SiteInstanceImportTests : IDisposable
// Rollback: a mid-apply failure persists NOTHING
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that applying rolls back the site and connection when the instance pass throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_rolls_back_site_and_connection_when_instance_pass_throws()
{
@@ -612,6 +624,9 @@ public sealed class SiteInstanceImportTests : IDisposable
// Overwrite an existing instance: child rows replaced from the bundle
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that an overwrite cannot reassign a deployed instance.</summary>
/// <param name="state">The state of the existing instance being overwritten.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(InstanceState.Enabled)]
[InlineData(InstanceState.NotDeployed)]
@@ -662,6 +677,8 @@ public sealed class SiteInstanceImportTests : IDisposable
}
}
/// <summary>Verifies that overwriting an existing instance replaces its child rows and remaps the binding.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding()
{
@@ -729,13 +746,15 @@ public sealed class SiteInstanceImportTests : IDisposable
// C1 — binding references a TARGET connection the bundle did NOT carry
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a binding to a target connection omitted from the bundle resolves to the existing id rather than zero.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_binding_to_target_connection_omitted_from_bundle_resolves_to_existing_id_not_zero()
{
// C1 regression: a valid bundle can carry an instance whose binding (and
// native-alarm-source override) references a connection that exists in the
// TARGET but was NOT carried in the bundle's DataConnections. Preview does
// NOT block it (it auto-matches the target). Before the C1 Pass-2 fix the
// NOT block it (it auto-matches the target). Before the second-pass fix the
// connection map MISSED for that binding → DataConnectionId defaulted to 0
// (an invalid FK). After the fix the map resolves the EXISTING target
// connection's id and both the binding + the native-alarm override rewrite
@@ -806,7 +825,7 @@ public sealed class SiteInstanceImportTests : IDisposable
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
// No explicit connection mapping — the connection auto-matches the
// existing target connection within plant-1 (the C1 Pass-2 path).
// existing target connection within plant 1 (the second-pass path).
Connections: Array.Empty<ConnectionMapping>());
var result = await ApplyAsync(
@@ -844,6 +863,8 @@ public sealed class SiteInstanceImportTests : IDisposable
Assert.Equal(1, result.Added);
}
/// <summary>Verifies that a binding to a connection in neither the bundle nor the target fails instead of writing a zero id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_binding_to_connection_in_neither_bundle_nor_target_fails_instead_of_writing_zero()
{
@@ -924,6 +945,8 @@ public sealed class SiteInstanceImportTests : IDisposable
// M-4 — native-alarm-source ConnectionNameOverride RENAME-REDIRECT
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a native alarm override's connection name is rewritten to the redirected target name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_native_alarm_override_connection_name_rewritten_to_redirected_target_name()
{
@@ -1049,14 +1072,16 @@ public sealed class SiteInstanceImportTests : IDisposable
// C2 — two sites with same-named connections resolve independently
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that two sites sharing a connection name are resolved independently per site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_two_sites_same_connection_name_apply_per_site_resolution_independently()
{
// C2 regression: connection names are unique only WITHIN a site, so a bundle
// with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must NOT collapse onto a
// with plant 1/OpcUaPrimary + plant 2/OpcUaPrimary must NOT collapse onto a
// single resolution. Both target connections exist; the operator Overwrites
// plant-1's and Skips plant-2's. The site-qualified resolution key routes the
// Overwrite to plant-1's connection ONLY and leaves plant-2's untouched.
// plant 1's and Skips plant 2's. The site-qualified resolution key routes the
// Overwrite to plant 1's connection ONLY and leaves plant 2's untouched.
int plant1ConnId, plant2ConnId;
await using (var scope = _provider.CreateAsyncScope())
{
@@ -1143,13 +1168,13 @@ public sealed class SiteInstanceImportTests : IDisposable
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// plant-1's connection was Overwritten with the bundle's config.
// plant 1's connection was Overwritten with the bundle's config.
var c1 = await ctx.DataConnections.SingleAsync(c => c.Id == plant1ConnId);
Assert.Contains("p1-from-bundle", c1.PrimaryConfiguration!);
Assert.Equal(9, c1.FailoverRetryCount);
// plant-2's same-named connection was LEFT UNTOUCHED by the Skip — the
// bare-name collision bug would have applied plant-1's Overwrite here too.
// plant 2's same-named connection was LEFT UNTOUCHED by the Skip — the
// bare-name collision bug would have applied plant 1's Overwrite here too.
var c2 = await ctx.DataConnections.SingleAsync(c => c.Id == plant2ConnId);
Assert.Contains("p2-existing", c2.PrimaryConfiguration!);
Assert.Equal(2, c2.FailoverRetryCount);
@@ -1158,15 +1183,17 @@ public sealed class SiteInstanceImportTests : IDisposable
Assert.Equal(2, await ctx.DataConnections.CountAsync(c => c.Name == "OpcUaPrimary"));
}
// One Overwrite (plant-1 conn), three Skips (two sites + plant-2 conn).
// One Overwrite (plant 1 conn), three Skips (two sites + plant 2 conn).
Assert.Equal(1, result.Overwritten);
Assert.Equal(3, result.Skipped);
}
// ──────────────────────────────────────────────────────────────────────
// #229 — empty-ConnectionName binding is silently SKIPPED (not written)
// Empty-ConnectionName binding is silently SKIPPED (not written)
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a binding with an empty connection name is skipped rather than written.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyAsync_empty_ConnectionName_binding_is_skipped_not_written()
{
@@ -1277,9 +1304,11 @@ public sealed class SiteInstanceImportTests : IDisposable
}
// ──────────────────────────────────────────────────────────────────────
// N4 (plan R2-05 T7): warn on overrides of LOCKED template members
// Warn on overrides of LOCKED template members
// ──────────────────────────────────────────────────────────────────────
/// <summary>Verifies that an override on a locked attribute emits a warning and the import still succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
{
@@ -1323,6 +1352,8 @@ public sealed class SiteInstanceImportTests : IDisposable
}
}
/// <summary>Verifies that a preview of an override on a locked native alarm source shows a warning row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
{
@@ -1344,6 +1375,8 @@ public sealed class SiteInstanceImportTests : IDisposable
&& b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that an override on an unlocked attribute produces no lock warning.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
{
@@ -1378,7 +1411,7 @@ public sealed class SiteInstanceImportTests : IDisposable
}
/// <summary>
/// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose
/// Seeds a template ("Pump") + site ("plant 1") + instance ("Pump 01") whose
/// overrides target the given members; <paramref name="lockAttribute"/> /
/// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag.
/// The attribute override is always present; the native-source override (and its
@@ -21,7 +21,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// #05-T3: a template script's <see cref="TemplateScript.MinTimeBetweenRuns"/> and
/// A template script's <see cref="TemplateScript.MinTimeBetweenRuns"/> and
/// <see cref="TemplateScript.ExecutionTimeoutSeconds"/> must survive an export→import
/// round-trip. The exporter serializes both fields (EntitySerializer) and the DTO
/// carries them; before this fix the importer's <c>BuildTemplate</c> (Add) and
@@ -33,6 +33,7 @@ public sealed class TemplateScriptFidelityTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with a fresh service provider and database.</summary>
public TemplateScriptFidelityTests()
{
var services = new ServiceCollection();
@@ -66,6 +67,7 @@ public sealed class TemplateScriptFidelityTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Disposes the service provider.</summary>
public void Dispose() => _provider.Dispose();
private async Task<Guid> ExportAndLoadAsync()
@@ -99,6 +101,8 @@ public sealed class TemplateScriptFidelityTests : IDisposable
return session.SessionId;
}
/// <summary>Verifies that adding a template script preserves its minimum time between runs and execution timeout.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Add_preserves_MinTimeBetweenRuns_and_ExecutionTimeoutSeconds()
{
@@ -147,6 +151,8 @@ public sealed class TemplateScriptFidelityTests : IDisposable
}
}
/// <summary>Verifies that overwriting updates the execution timeout, shows it as the only diff, and emits an audit entry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Overwrite_updates_ExecutionTimeoutSeconds_only_diff_and_emits_audit()
{
@@ -200,10 +206,12 @@ public sealed class TemplateScriptFidelityTests : IDisposable
}
}
/// <summary>Verifies that adding preserves the locked-in-derived flag on attributes, alarms and scripts.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Add_preserves_LockedInDerived_on_attribute_alarm_script()
{
// #05-T4: LockedInDerived must survive export→import on all three child kinds.
// LockedInDerived must survive export→import on all three child kinds.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
@@ -26,7 +26,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
/// <summary>
/// #05-T8 — per-entity <b>structural</b> round-trip guard.
/// Per-entity <b>structural</b> round-trip guard.
/// <para>
/// For every transported entity type, seed a MAXIMALLY populated entity (every
/// writable data property set to a non-default value), export it through the real
@@ -78,6 +78,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes a new instance of <c>RoundTripEquivalenceTests</c>.</summary>
public RoundTripEquivalenceTests()
{
var services = new ServiceCollection();
@@ -111,6 +112,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose() => _provider.Dispose();
// ──────────────────────────────────────────────────────────────────────
@@ -187,6 +189,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// Template + its five child kinds + folders
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip template closure preserves every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_template_closure_preserves_every_writable_property()
{
@@ -336,6 +340,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
.Include(t => t.Compositions)
.SingleAsync(t => t.Name == name);
/// <summary>Verifies that round trip template folders preserve hierarchy and sort order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_template_folders_preserve_hierarchy_and_sort_order()
{
@@ -386,7 +392,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
AssertRoundTripEqual(srcChild, impChild,
nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId));
// (RED) HOLE #5 — folder hierarchy is flattened on import Add: the folder
// (RED) Folder hole — folder hierarchy is flattened on import Add: the folder
// Add pass builds `new TemplateFolder(dto.Name) { SortOrder }` and never
// consults the DTO's ParentName, so the child lands parent-less. The
// serializer-level Roundtrip_template_folder_preserves_hierarchy test is
@@ -399,6 +405,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// SharedScript
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip shared script preserves every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_shared_script_preserves_every_writable_property()
{
@@ -440,6 +448,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// ExternalSystemDefinition + ExternalSystemMethod
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip external system and method preserve every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_external_system_and_method_preserve_every_writable_property()
{
@@ -450,7 +460,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
AuthConfiguration = "{\"apiKey\":\"super-secret\"}", // rides SecretsBlock
MaxRetries = 6,
RetryDelay = TimeSpan.FromSeconds(17),
// arch-review 2026-08-19 P7-1: a default-0 fixture value made this
// A default (zero) fixture value made this
// reflection guard vacuous for TimeoutSeconds (comparing default against
// default survives even a serializer that drops the field entirely) — a
// non-default value makes the guard actually exercise the field.
@@ -495,14 +505,14 @@ public sealed class RoundTripEquivalenceTests : IDisposable
{
var impSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync();
// (RED) HOLES #1/#2 — MaxRetries + RetryDelay are dropped on import Add:
// (RED) MaxRetries + RetryDelay are dropped on import Add:
// BuildExternalSystem builds `new ExternalSystemDefinition(name, url, authType)
// { AuthConfiguration }` and never copies the DTO's retry config. The
// serializer-level Roundtrip_external_system_preserves_retry_config test is
// GREEN — the loss is purely in BundleImporter's Add path.
AssertRoundTripEqual(srcSys, impSys, nameof(ExternalSystemDefinition.Id));
// (RED) HOLE #3 — ExternalSystemMethod rows are dropped on import Add:
// (RED) ExternalSystemMethod rows are dropped on import Add:
// only the Overwrite path calls SyncExternalSystemMethodsAsync; the Add path
// carries no methods (the definition entity has no Methods navigation to
// cascade). The serializer-level round-trip carries them; the pipeline loses
@@ -521,6 +531,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// DatabaseConnectionDefinition (External-System DB connection)
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip database connection preserves every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_database_connection_preserves_every_writable_property()
{
@@ -564,6 +576,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// NotificationList + NotificationRecipient
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip notification list and recipient preserve every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_notification_list_and_recipient_preserve_every_writable_property()
{
@@ -609,7 +623,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// List scalars (Name, Type) round-trip (GREEN).
AssertRoundTripEqual(srcList, impList, nameof(NotificationList.Id));
// (RED) HOLE #4 — recipients are dropped on EXPORT: the dependency
// (RED) Recipients are dropped on EXPORT: the dependency
// resolver loads each selected list via GetNotificationListByIdAsync, which
// uses FindAsync(id) with NO Include(Recipients), so the exported DTO carries
// an empty Recipients collection and the import adds a recipient-less list.
@@ -629,6 +643,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// SmtpConfiguration
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip smtp configuration preserves every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_smtp_configuration_preserves_every_writable_property()
{
@@ -681,6 +697,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// SmsConfiguration
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip sms configuration preserves every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_sms_configuration_preserves_every_writable_property()
{
@@ -726,6 +744,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// ApiMethod
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip api method preserves every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_api_method_preserves_every_writable_property()
{
@@ -770,6 +790,8 @@ public sealed class RoundTripEquivalenceTests : IDisposable
// Site + DataConnection + Instance + all four instance-child kinds
// ══════════════════════════════════════════════════════════════════════
/// <summary>Verifies that round trip site closure preserves every writable property.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RoundTrip_site_closure_preserves_every_writable_property()
{
@@ -39,6 +39,7 @@ public sealed class RoundTripTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes a new instance of the <see cref="RoundTripTests"/> class with an in-memory database and the Transport services.</summary>
public RoundTripTests()
{
var services = new ServiceCollection();
@@ -66,7 +67,7 @@ public sealed class RoundTripTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
@@ -76,8 +77,11 @@ public sealed class RoundTripTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Disposes the underlying service provider.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>Verifies that exporting, wiping and re-importing a bundle restores the original state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Export_then_wipe_then_import_restores_state()
{
@@ -236,7 +240,7 @@ public sealed class RoundTripTests : IDisposable
.Include(t => t.Compositions)
.SingleAsync(t => t.Name == "Pump");
Assert.Equal("composes BaseDevice", pump.Description);
// FU-B / #39 — composition graph IS restored on import. The bundle
// Composition graph IS restored on import. The bundle
// carried Pump composing BaseDevice via InstanceName="base"; the
// importer's second pass (ResolveCompositionEdgesAsync) re-resolved
// ComposedTemplateName to BaseDevice's new id after the template
@@ -302,6 +306,8 @@ public sealed class RoundTripTests : IDisposable
Assert.NotEqual(Guid.Empty, result.BundleImportId);
}
/// <summary>Verifies that exporting and re-importing a bundle restores the SMS configuration with its secret intact.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Export_then_import_restores_sms_config_with_secret_intact()
{
@@ -39,6 +39,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with an in-memory service provider wired for Transport.</summary>
public SemanticValidatorImportTests()
{
var services = new ServiceCollection();
@@ -55,7 +56,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
@@ -65,6 +66,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>
@@ -81,7 +83,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
// #05-T19: also carry any seeded ApiMethods so the severity-split
// Also carry any seeded ApiMethods so the severity-split
// tests (template-script = warning, ApiMethod = hard error) exercise
// both source kinds through the same export→load→apply path.
var apiIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
@@ -198,10 +200,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
}
}
/// <summary>Verifies that a template script with an unresolved reference produces a warning but does not block the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import()
{
// #05-T19 — a template whose script body calls UnknownHelper(): a
// A template whose script body calls UnknownHelper(): a
// PascalCase identifier that doesn't resolve to any SharedScript or
// ExternalSystem in the bundle or the target. Under the severity split,
// template-script name-resolution findings are ADVISORY (the design-time
@@ -244,10 +248,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
}
}
/// <summary>Verifies that a template script with a local PascalCase method does not hard-block the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_TemplateScriptWithLocalPascalCaseMethod_DoesNotHardBlock()
{
// #05-T19 — a template script that DECLARES and calls its own local
// A template script that DECLARES and calls its own local
// PascalCase function. The identifier scan would otherwise flag
// ComputeRate as a missing SharedScript reference; the local-declaration
// exclusion (Roslyn-parsed local functions/methods) removes it, so the
@@ -284,10 +290,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
w => w.Contains("ComputeRate", StringComparison.Ordinal));
}
/// <summary>Verifies that a template script using Regex and StringBuilder does not hard-block the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_TemplateScriptWithRegexAndStringBuilder_DoesNotHardBlock()
{
// #05-T19 — a template script using stdlib helpers that the extended
// A template script using stdlib helpers that the extended
// denylist now recognizes (Regex, StringBuilder, …). None resolve to a
// user SharedScript/ExternalSystem, and none should be flagged.
await using (var scope = _provider.CreateAsyncScope())
@@ -326,10 +334,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
|| w.Contains("StringBuilder", StringComparison.Ordinal));
}
/// <summary>Verifies that an API method with an unresolved reference still hard-blocks the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_ApiMethodWithUnresolvedReference_StillHardBlocks()
{
// #05-T19 — an ApiMethod script referencing a genuinely-missing
// An ApiMethod script referencing a genuinely-missing
// ExternalSystem. ApiMethod findings have no downstream deploy gate, so
// they remain HARD errors: the apply throws SemanticValidationException.
await using (var scope = _provider.CreateAsyncScope())
@@ -368,10 +378,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
}
}
/// <summary>Verifies that a template script using a forbidden API hard-blocks the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_TemplateScriptWithForbiddenApi_HardBlocks()
{
// #05-T20 — bundle import is the fifth script-trust call site. A template
// Bundle import is the fifth script-trust call site. A template
// script that reaches a forbidden API (Process.Start) must be rejected at
// import review, NOT left to fail at runtime. Unlike the name-resolution
// heuristic, a trust verdict is authoritative → hard error for ALL kinds.
@@ -411,10 +423,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
}
}
/// <summary>Verifies that an API method using a forbidden API hard-blocks the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_ApiMethodWithForbiddenApi_HardBlocks()
{
// #05-T20 — same trust gate over ApiMethod script bodies.
// Same trust gate over ApiMethod script bodies.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
@@ -443,10 +457,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
}
/// <summary>Verifies that an alarm Expression trigger using a forbidden API hard-blocks the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_AlarmExpressionTriggerWithForbiddenApi_HardBlocks()
{
// #05-T20 (code-review fix): an alarm's Expression trigger compiles and
// An alarm's Expression trigger compiles and
// executes at the site, so a forbidden API in the trigger expression must
// be caught at import — not just in template/shared/ApiMethod script bodies.
await using (var scope = _provider.CreateAsyncScope())
@@ -482,6 +498,8 @@ public sealed class SemanticValidatorImportTests : IDisposable
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
}
/// <summary>Verifies that an instance alarm override expression using a forbidden API hard-blocks the import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks()
{
@@ -518,6 +536,8 @@ public sealed class SemanticValidatorImportTests : IDisposable
// pre-exist, but no new import ran — the throw is pre-write at Pass 0).
}
/// <summary>Verifies that previewing an instance alarm override expression with a forbidden API surfaces a blocker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker()
{
@@ -538,6 +558,8 @@ public sealed class SemanticValidatorImportTests : IDisposable
&& b.BlockerReason!.Contains("trust violation", StringComparison.Ordinal));
}
/// <summary>Verifies that a clean instance alarm override expression imports successfully.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports()
{
@@ -587,10 +609,12 @@ public sealed class SemanticValidatorImportTests : IDisposable
}
}
/// <summary>Verifies that an API method reference is not suppressed by a template-local function of the same name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
{
// #05-T19 (code-review fix): a local function declared in a TEMPLATE script
// A local function declared in a TEMPLATE script
// must not suppress a genuinely-missing reference of the same name in an
// ApiMethod — the ApiMethod finding stays a HARD error. A pre-fix global
// locallyDeclared set silently defeated this.
@@ -628,6 +652,8 @@ public sealed class SemanticValidatorImportTests : IDisposable
Assert.Contains(ex.Errors, err => err.Contains("SharedHelper", StringComparison.Ordinal));
}
/// <summary>Verifies that the semantic validator catches an alarm trigger type mismatch at import.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
{
@@ -685,6 +711,8 @@ public sealed class SemanticValidatorImportTests : IDisposable
}
}
/// <summary>Verifies that a valid bundle passes semantic validation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Valid_bundle_passes_semantic_validation()
{
@@ -28,6 +28,7 @@ public sealed class ValidationFailureTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes a new instance of the <c>ValidationFailureTests</c> class.</summary>
public ValidationFailureTests()
{
var services = new ServiceCollection();
@@ -44,7 +45,7 @@ public sealed class ValidationFailureTests : IDisposable
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
// M8: DependencyResolver now injects ISiteRepository to walk the
// DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
@@ -54,8 +55,11 @@ public sealed class ValidationFailureTests : IDisposable
_provider = services.BuildServiceProvider();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>Verifies that a semantic validation failure rolls back all writes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Semantic_validation_failure_rolls_back_all_writes()
{
@@ -64,8 +68,7 @@ public sealed class ValidationFailureTests : IDisposable
// a SharedScript that is NOT in the bundle (we don't seed one) and
// also NOT in the target after the wipe. ApiMethod name-resolution
// findings are HARD errors (no downstream deploy gate re-validates
// them — unlike template-script findings, which are advisory as of
// #05-T19), so this is the vehicle that still forces a rollback.
// them — unlike template-script findings, which are advisory), so this is the vehicle that still forces a rollback.
// - Export the ApiMethod only (no helper alongside).
// - Wipe the target so the apply runs through Add-paths.
// - Snapshot row counts so the rollback assertion is unambiguous.