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:
@@ -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()
|
||||
{
|
||||
|
||||
+14
-2
@@ -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>();
|
||||
|
||||
+70
-14
@@ -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.");
|
||||
}
|
||||
|
||||
+42
-8
@@ -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()
|
||||
{
|
||||
|
||||
+8
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
+4
@@ -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()
|
||||
{
|
||||
|
||||
+35
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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()
|
||||
{
|
||||
|
||||
+13
-1
@@ -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()
|
||||
{
|
||||
|
||||
+51
-18
@@ -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
|
||||
|
||||
+10
-2
@@ -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>();
|
||||
|
||||
+28
-6
@@ -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()
|
||||
{
|
||||
|
||||
+38
-10
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user