diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs new file mode 100644 index 00000000..d7e8903b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IStaleInstanceProbe.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; + +/// +/// Computes the CURRENT flattened-config revision hash of a deployed instance — +/// the same value the deployment pipeline produces and stores in +/// DeployedConfigSnapshot.RevisionHash. Transport's bundle importer uses +/// this to enumerate stale instances after a template overwrite (#16): an +/// instance is stale when its freshly-computed hash no longer matches the hash +/// captured at deploy time. +/// +/// The seam lives in Commons so the Transport component can depend on it without +/// referencing the DeploymentManager project (where the flattening pipeline that +/// implements it lives). The implementation re-flattens the instance via the +/// existing flattening pipeline over the SAME scoped DbContext, so it sees +/// any template rows the in-flight import has staged on the change tracker but +/// not yet committed. +/// +/// +public interface IStaleInstanceProbe +{ + /// + /// Computes the current flattened-config revision hash for the given instance, + /// or null when the hash cannot be computed (e.g. the instance or its + /// template chain cannot be resolved). Callers treat a null result as + /// "cannot determine staleness" and skip the instance — staleness is + /// informational, never a reason to abort an import. + /// + /// The target instance's surrogate id. + /// Cancellation token. + /// The current revision hash (e.g. sha256:...), or null. + Task GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs index 916af136..5b8d5195 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager; @@ -40,6 +41,13 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // #16 (M8 D2): expose the flattening pipeline's revision-hash computation + // to the Transport bundle importer through the Commons IStaleInstanceProbe + // seam, so a template overwrite can enumerate the deployed instances whose + // flattened config has drifted from their deployed snapshot. Scoped so it + // shares the per-request DbContext with the in-flight import. + services.AddScoped(); return services; } diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs new file mode 100644 index 00000000..87d501ee --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/StaleInstanceProbe.cs @@ -0,0 +1,38 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; + +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager; + +/// +/// backed by the deployment flattening pipeline. +/// Re-flattens an instance through and returns +/// the computed revision hash — the exact same value +/// DeploymentService.GetDeploymentComparisonAsync compares against the +/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the +/// Transport component (which references only Commons + TemplateEngine) can probe +/// staleness without taking a DeploymentManager project reference. +/// +public sealed class StaleInstanceProbe : IStaleInstanceProbe +{ + private readonly IFlatteningPipeline _flatteningPipeline; + + /// Initializes a new . + /// The deployment flattening pipeline used to recompute the current revision hash. + public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline) + { + _flatteningPipeline = flatteningPipeline ?? throw new ArgumentNullException(nameof(flatteningPipeline)); + } + + /// + public async Task GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default) + { + // The pipeline returns a Result; a flatten failure (e.g. unresolvable + // template chain mid-import) yields null so the caller treats the + // instance as "staleness indeterminate" and skips it. Flattening reuses + // the scoped ITemplateEngineRepository, so it observes template rows the + // in-flight import has staged on the shared change tracker. + var result = await _flatteningPipeline + .FlattenAndValidateAsync(instanceId, cancellationToken) + .ConfigureAwait(false); + return result.IsSuccess ? result.Value.RevisionHash : null; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 634ace03..c91d64e5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1,6 +1,7 @@ using System.IO.Compression; using System.Security.Cryptography; using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; @@ -73,6 +74,11 @@ public sealed class BundleImporter : IBundleImporter private readonly IOptions _options; private readonly TimeProvider _timeProvider; private readonly SemanticValidator _semanticValidator; + // #16 (M8 D2): optional. Implemented in DeploymentManager (where the flattening + // pipeline lives) and surfaced via the Commons IStaleInstanceProbe seam. Null + // in a host that registers Transport without DeploymentManager — staleness is + // then best-effort empty (informational only, never gates the import). + private readonly IStaleInstanceProbe? _staleInstanceProbe; private readonly ILogger? _logger; /// @@ -95,6 +101,8 @@ public sealed class BundleImporter : IBundleImporter /// Correlation context that carries the active BundleImportId. /// EF Core context used to commit the import transaction. /// Validates template references before applying. + /// Optional (#16): recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped. + /// Optional logger. public BundleImporter( BundleSerializer bundleSerializer, ManifestValidator manifestValidator, @@ -113,6 +121,7 @@ public sealed class BundleImporter : IBundleImporter IAuditCorrelationContext correlationContext, ScadaBridgeDbContext dbContext, SemanticValidator semanticValidator, + IStaleInstanceProbe? staleInstanceProbe = null, ILogger? logger = null) { _bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer)); @@ -132,6 +141,7 @@ public sealed class BundleImporter : IBundleImporter _correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext)); _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator)); + _staleInstanceProbe = staleInstanceProbe; _logger = logger; } @@ -1047,6 +1057,8 @@ public sealed class BundleImporter : IBundleImporter // Array.Empty() placeholder). The single deferred SaveChangesAsync // is the next statement, so a read against the change tracker here sees // the full post-apply graph before commit. + var staleInstanceIds = await ComputeStaleInstanceIdsAsync( + content, resolutionMap, ct).ConfigureAwait(false); await _auditService.LogAsync( user: user, @@ -1089,7 +1101,7 @@ public sealed class BundleImporter : IBundleImporter Overwritten: summary.Overwritten, Skipped: summary.Skipped, Renamed: summary.Renamed, - StaleInstanceIds: Array.Empty(), + StaleInstanceIds: staleInstanceIds, AuditEventCorrelation: bundleImportId.ToString(), ApiKeysIgnored: apiKeysIgnored); } @@ -1197,6 +1209,113 @@ public sealed class BundleImporter : IBundleImporter } } + /// + /// #16 (M8 D2): enumerates the deployed instances stale-ed by this import. + /// Overwriting a template changes the flattened-config hash of every instance + /// of that template, so a deployed instance's freshly-computed revision hash + /// will no longer match the hash captured in its DeployedConfigSnapshot + /// at deploy time — it surfaces as "stale" on the Deployments page. This walks + /// the templates THIS import OVERWROTE, finds each one's deployed instances + /// (State / + /// with a snapshot), recomputes their current hash through + /// (which reads the just-staged template + /// rows on the shared change tracker), and collects the ids whose hash drifted. + /// + /// Freshly-IMPORTED instances are excluded structurally: they enter the target + /// (no snapshot), so they're "new", not + /// "stale". Best-effort: a probe that returns null or throws for one instance + /// is skipped (logged), never aborting the import — staleness is informational + /// and the import's own success path is authoritative. Returns an empty list + /// when no probe is wired (Transport-without-DeploymentManager hosts). + /// + /// + private async Task> ComputeStaleInstanceIdsAsync( + BundleContentDto content, + Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap, + CancellationToken ct) + { + // No flattening pipeline registered → can't recompute hashes. Staleness is + // informational, so degrade gracefully to "none". + if (_staleInstanceProbe is null || content.Templates.Count == 0) + { + return Array.Empty(); + } + + // Which templates did THIS import overwrite? A bundle template is + // overwritten when its resolution Action == Overwrite AND a target template + // of that name already exists (otherwise the Overwrite branch falls through + // to Add — see ApplyTemplatesAsync). Map those names to their target ids. + var targetTemplateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false)) + .GroupBy(t => t.Name, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal); + + var overwrittenTemplateIds = new HashSet(); + foreach (var dto in content.Templates) + { + var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name); + if (resolution.Action != ResolutionAction.Overwrite) continue; + if (targetTemplateIdByName.TryGetValue(dto.Name, out var templateId)) + { + overwrittenTemplateIds.Add(templateId); + } + } + + if (overwrittenTemplateIds.Count == 0) + { + return Array.Empty(); + } + + var staleIds = new List(); + foreach (var templateId in overwrittenTemplateIds) + { + var instances = await _templateRepo + .GetInstancesByTemplateIdAsync(templateId, ct).ConfigureAwait(false); + foreach (var instance in instances) + { + // NotDeployed instances (including everything this import just + // created/overwrote) are "new", not "stale" — skip them. + if (instance.State is not (InstanceState.Enabled or InstanceState.Disabled)) + { + continue; + } + + try + { + // A deployed instance has a snapshot; its absence means the + // instance is not actually deployed (defensive — State already + // gated this) so there's no baseline to compare against. + var snapshot = await _dbContext.DeployedConfigSnapshots + .AsNoTracking() + .Where(s => s.InstanceId == instance.Id) + .OrderByDescending(s => s.DeployedAt) + .FirstOrDefaultAsync(ct) + .ConfigureAwait(false); + if (snapshot is null) continue; + + var currentHash = await _staleInstanceProbe + .GetCurrentRevisionHashAsync(instance.Id, ct).ConfigureAwait(false); + // null => hash indeterminate; don't claim staleness either way. + if (currentHash is null) continue; + + if (!string.Equals(currentHash, snapshot.RevisionHash, StringComparison.Ordinal)) + { + staleIds.Add(instance.Id); + } + } + catch (Exception ex) + { + // Best-effort: a single instance's hash failure must not abort + // the (already-successful) import. Skip it and move on. + _logger?.LogWarning(ex, + "Stale-instance hash computation failed for instance {InstanceId} (template {TemplateId}); skipping.", + instance.Id, templateId); + } + } + } + + return staleIds; + } + /// Mutable per-apply counter struct, accumulated through every helper. private sealed class ImportSummary { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs index 023511b6..2c8c0db2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs @@ -2,7 +2,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; @@ -13,6 +15,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using ZB.MOM.WW.ScadaBridge.TemplateEngine; using ZB.MOM.WW.ScadaBridge.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Import; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; @@ -64,9 +68,16 @@ public sealed class BundleImporterApplyTests : IDisposable // M8: DependencyResolver now injects ISiteRepository to walk the // site/data-connection/instance closure; register it or activation fails. services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); + // #16 (M8 D2): 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. + services.AddTemplateEngine(); + services.AddDeploymentManager(); _provider = services.BuildServiceProvider(); } @@ -926,4 +937,238 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.Contains(preview.Items, item => item.EntityType == "ApiMethod" && item.Name == "GetStatus"); } + + // ───────────────────────────────────────────────────────────────────── + // #16 (M8 D2): 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 + // never stale. + // ───────────────────────────────────────────────────────────────────── + + /// + /// Seeds an Enabled instance of and a + /// whose RevisionHash is the GENUINE hash + /// the deployment flattening pipeline computes for the instance's CURRENT + /// (pre-import) config. Returns the instance id. Mirrors how the deploy path + /// captures a snapshot so the staleness comparison is faithful. + /// + private async Task SeedDeployedInstanceWithRealSnapshotAsync( + string templateName, string instanceName) + { + int instanceId; + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var template = await ctx.Templates.SingleAsync(t => t.Name == templateName); + var instance = new Instance(instanceName) + { + TemplateId = template.Id, + SiteId = (await ctx.Sites.FirstAsync()).Id, + State = InstanceState.Enabled, + }; + ctx.Instances.Add(instance); + await ctx.SaveChangesAsync(); + instanceId = instance.Id; + + // Compute the REAL current revision hash via the same pipeline the + // deploy path uses, then persist it as the deployed snapshot so the + // import's drift comparison runs against a genuine baseline. + var pipeline = scope.ServiceProvider.GetRequiredService(); + var flattened = await pipeline.FlattenAndValidateAsync(instanceId); + Assert.True(flattened.IsSuccess, + $"Seed flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}"); + ctx.DeployedConfigSnapshots.Add(new DeployedConfigSnapshot( + deploymentId: Guid.NewGuid().ToString(), + revisionHash: flattened.Value.RevisionHash, + configurationJson: System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration)) + { + InstanceId = instanceId, + DeployedAt = DateTimeOffset.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + return instanceId; + } + + private async Task SeedSiteAsync() + { + await using var scope = _provider.CreateAsyncScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant 1", "plant-1") + { + NodeAAddress = "akka://site@10.0.0.1:2552", + NodeBAddress = "akka://site@10.0.0.2:2552", + }; + ctx.Sites.Add(site); + await ctx.SaveChangesAsync(); + return site.Id; + } + + [Fact] + public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale() + { + // Arrange — seed Pump with the BUNDLE's (new) attribute shape, export it, + // then mutate the target template + reseed the deployed snapshot to the + // OLD shape so an Overwrite restores the new shape and drifts the deployed + // instance off its captured hash. + await SeedSiteAsync(); + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("Pump") { Description = "v2" }; + t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" }); + t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" }); // extra attr in bundle + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + + // Mutate the target template to the OLD shape (drop Pressure, change Flow), + // then deploy an instance capturing the OLD config's hash. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump"); + t.Description = "v1"; + var pressure = t.Attributes.Single(a => a.Name == "Pressure"); + t.Attributes.Remove(pressure); + ctx.TemplateAttributes.Remove(pressure); + t.Attributes.Single(a => a.Name == "Flow").Value = "0.0"; + await ctx.SaveChangesAsync(); + } + var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed"); + + // Act — Overwrite Pump back to the bundle's v2 shape. + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, + user: "bob"); + } + + // Assert — the deployed instance drifted off its snapshot hash and is stale. + Assert.Equal(1, result.Overwritten); + Assert.Contains(deployedInstanceId, result.StaleInstanceIds); + } + + [Fact] + public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale() + { + // Two templates: Pump (overwritten with a CHANGED shape) and Valve + // (overwritten with an IDENTICAL shape — its instance must NOT be stale). + // The export carries both at their seeded shape; we mutate only Pump in + // the target so its overwrite changes the hash, while Valve's overwrite + // is a no-op hash-wise. + await SeedSiteAsync(); + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var pump = new Template("Pump") { Description = "v2" }; + pump.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" }); + pump.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" }); + ctx.Templates.Add(pump); + var valve = new Template("Valve") { Description = "stable" }; + valve.Attributes.Add(new TemplateAttribute("Position") { DataType = DataType.Float, Value = "0.5" }); + ctx.Templates.Add(valve); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + + // Mutate ONLY Pump in the target so its overwrite changes the hash; leave + // Valve identical to the bundle so its overwrite is hash-neutral. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var pump = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump"); + var pressure = pump.Attributes.Single(a => a.Name == "Pressure"); + pump.Attributes.Remove(pressure); + ctx.TemplateAttributes.Remove(pressure); + await ctx.SaveChangesAsync(); + } + var pumpInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed"); + var valveInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Valve", "Valve-Deployed"); + + // Act — Overwrite BOTH templates. + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List + { + new("Template", "Pump", ResolutionAction.Overwrite, null), + new("Template", "Valve", ResolutionAction.Overwrite, null), + }, + user: "bob"); + } + + // Assert — only Pump's deployed instance is stale; Valve's is not. + Assert.Contains(pumpInstanceId, result.StaleInstanceIds); + Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds); + } + + [Fact] + public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale() + { + // A bundle that carries a template AND an instance of it: the imported + // instance enters the target NotDeployed (no snapshot), so even though the + // template is "new" to the target it must NOT appear in StaleInstanceIds. + // We also overwrite an EXISTING deployed instance's template to prove the + // stale list contains the deployed one but never the fresh one. + await SeedSiteAsync(); + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("Pump") { Description = "v2" }; + t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" }); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + + // Mutate the target template, then deploy an instance capturing the OLD hash. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump"); + t.Attributes.Single(a => a.Name == "Flow").Value = "0.0"; + await ctx.SaveChangesAsync(); + } + var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed"); + + // Also seed a NotDeployed instance of the same template (simulates a + // never-deployed / freshly-imported instance) — it has no snapshot. + int notDeployedInstanceId; + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); + var fresh = new Instance("Pump-Fresh") + { + TemplateId = t.Id, + SiteId = (await ctx.Sites.FirstAsync()).Id, + State = InstanceState.NotDeployed, + }; + ctx.Instances.Add(fresh); + await ctx.SaveChangesAsync(); + notDeployedInstanceId = fresh.Id; + } + + // Act — Overwrite Pump. + ImportResult result; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + result = await importer.ApplyAsync(sessionId, + new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, + user: "bob"); + } + + // Assert — deployed instance is stale; the NotDeployed one is excluded. + Assert.Contains(deployedInstanceId, result.StaleInstanceIds); + Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs index 2604d5a4..aedbf179 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs @@ -323,7 +323,8 @@ public sealed class SiteInstanceImportTests : IDisposable // Counts: site + connection + instance added (template was Skipped). Assert.Equal(3, result.Added); Assert.Equal(1, result.Skipped); - // D2 has not run yet — StaleInstanceIds stays empty. + // No template was overwritten and the imported instance is NotDeployed, so + // the D2 (#16) stale-instance enumeration finds nothing. Assert.Empty(result.StaleInstanceIds); } @@ -866,6 +867,131 @@ public sealed class SiteInstanceImportTests : IDisposable } } + // ────────────────────────────────────────────────────────────────────── + // M-4 — native-alarm-source ConnectionNameOverride RENAME-REDIRECT + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ApplyAsync_native_alarm_override_connection_name_rewritten_to_redirected_target_name() + { + // M-4 regression. The existing tests only cover the same-name identity case + // (source connection name == target connection name), so a bug that fails to + // rewrite ConnectionNameOverride on a RENAME-REDIRECT would go unnoticed. + // Here the operator maps the source connection "SourceConn" to a + // DIFFERENTLY-named existing target connection "TargetConn" via an explicit + // MapToExisting. After import, the imported instance's native-alarm-source + // override (and its connection binding) must carry the TARGET name, not the + // source's. + int targetConnId; + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" }); + var site = new Site("Plant 1", "plant-1"); + ctx.Sites.Add(site); + await ctx.SaveChangesAsync(); + // The target connection is named DIFFERENTLY from the source's. + var conn = new DataConnection("TargetConn", "OpcUa", site.Id) + { + PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target\"}", + }; + ctx.DataConnections.Add(conn); + await ctx.SaveChangesAsync(); + targetConnId = conn.Id; + } + + // Hand-pack a bundle whose instance references the SOURCE connection name + // "SourceConn" (both in its binding and its native-alarm override). The + // bundle does NOT carry the connection — the redirect resolves it against + // the existing target "TargetConn". + var content = new BundleContentDto( + TemplateFolders: Array.Empty(), + Templates: Array.Empty(), + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()) + { + Sites = new[] + { + new SiteDto("plant-1", "Plant 1", null, null, null, null, null), + }, + DataConnections = Array.Empty(), + Instances = new[] + { + new InstanceDto( + UniqueName: "Pump-01", + TemplateName: "Pump", + SiteIdentifier: "plant-1", + AreaName: null, + State: InstanceState.Enabled, + AttributeOverrides: Array.Empty(), + AlarmOverrides: Array.Empty(), + NativeAlarmSourceOverrides: new[] + { + new InstanceNativeAlarmSourceOverrideDto( + SourceCanonicalName: "NativeSrc", + ConnectionNameOverride: "SourceConn", + SourceReferenceOverride: "ns=3;s=Pump.Alarm", + ConditionFilterOverride: null), + }, + ConnectionBindings: new[] + { + new InstanceConnectionBindingDto( + AttributeName: "Flow", + ConnectionName: "SourceConn", + DataSourceReferenceOverride: "ns=3;s=Pump.Flow"), + }), + }, + }; + var sessionId = await PackAndLoadAsync(content); + + // Explicit redirect: source "SourceConn" → target "TargetConn" (DIFFERENT name). + var nameMap = new BundleNameMap( + Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") }, + Connections: new[] + { + new ConnectionMapping("plant-1", "SourceConn", MappingAction.MapToExisting, "TargetConn"), + }); + + var result = await ApplyAsync( + sessionId, + new List + { + new("Site", "plant-1", ResolutionAction.Skip, null), + new("Instance", "Pump-01", ResolutionAction.Add, null), + }, + nameMap); + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + + // No new connection created — the redirect resolved the existing target. + var conn = Assert.Single(await ctx.DataConnections.ToListAsync()); + Assert.Equal("TargetConn", conn.Name); + Assert.Equal(targetConnId, conn.Id); + + var inst = await ctx.Instances + .Include(i => i.ConnectionBindings) + .Include(i => i.NativeAlarmSourceOverrides) + .SingleAsync(i => i.UniqueName == "Pump-01"); + + // THE FIX: the native-alarm-source override carries the TARGET name, + // not the source's "SourceConn". + var native = Assert.Single(inst.NativeAlarmSourceOverrides); + Assert.Equal("TargetConn", native.ConnectionNameOverride); + + // The connection binding FK rewires to the redirected target connection. + var binding = Assert.Single(inst.ConnectionBindings); + Assert.Equal(targetConnId, binding.DataConnectionId); + } + + Assert.Equal(1, result.Added); + } + // ────────────────────────────────────────────────────────────────────── // C2 — two sites with same-named connections resolve independently // ──────────────────────────────────────────────────────────────────────