diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 0cfb2d99..5f78eb76 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; @@ -65,6 +66,7 @@ public sealed class BundleImporter : IBundleImporter private readonly IExternalSystemRepository _externalRepo; private readonly INotificationRepository _notificationRepo; private readonly IInboundApiRepository _inboundApiRepo; + private readonly ISiteRepository _siteRepo; private readonly IBundleSessionStore _sessionStore; private readonly BundleUnlockRateLimiter _unlockRateLimiter; private readonly IOptions _options; @@ -87,6 +89,7 @@ public sealed class BundleImporter : IBundleImporter /// External system repository for diff and apply. /// Notification repository for diff and apply. /// Inbound API repository for diff and apply. + /// Site repository — supplies the target sites and site-scoped data connections that the preview's site/connection auto-match resolves against (M8 C2). /// Audit service for writing per-entity import audit rows. /// Correlation context that carries the active BundleImportId. /// EF Core context used to commit the import transaction. @@ -104,6 +107,7 @@ public sealed class BundleImporter : IBundleImporter IExternalSystemRepository externalRepo, INotificationRepository notificationRepo, IInboundApiRepository inboundApiRepo, + ISiteRepository siteRepo, IAuditService auditService, IAuditCorrelationContext correlationContext, ScadaBridgeDbContext dbContext, @@ -122,6 +126,7 @@ public sealed class BundleImporter : IBundleImporter _externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo)); _notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo)); _inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo)); + _siteRepo = siteRepo ?? throw new ArgumentNullException(nameof(siteRepo)); _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); _correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext)); _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); @@ -427,10 +432,195 @@ public sealed class BundleImporter : IBundleImporter items.Add(_diff.CompareApiMethod(m, existing)); } - // ---- Blocker detection ---- - items.AddRange(await DetectBlockersAsync(content, ct).ConfigureAwait(false)); + // ---- M8 site/instance-scoped types ---- + // Sites/DataConnections/Instances are referenced by stable string identity + // (SiteIdentifier / connection Name / UniqueName) and resolved against the + // TARGET environment's own surrogate keys. We auto-match the source site by + // identifier; the result drives both the per-type diffs (a matched target + // connection / instance feeds CompareDataConnection / CompareInstance) and + // the operator-facing required-mapping list built below. + // + // Cache target-site lookups + their data connections so we don't re-query + // the same site once per instance / connection / native-alarm override. + var targetSiteByIdentifier = new Dictionary(StringComparer.Ordinal); + var targetConnectionsBySiteIdentifier = + new Dictionary>(StringComparer.Ordinal); - return new ImportPreview(sessionId, items); + async Task ResolveTargetSiteAsync(string siteIdentifier) + { + if (targetSiteByIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached; + var site = await _siteRepo.GetSiteByIdentifierAsync(siteIdentifier, ct).ConfigureAwait(false); + targetSiteByIdentifier[siteIdentifier] = site; + return site; + } + + async Task> ResolveTargetConnectionsAsync(string siteIdentifier) + { + if (targetConnectionsBySiteIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached; + var site = await ResolveTargetSiteAsync(siteIdentifier).ConfigureAwait(false); + IReadOnlyList conns = site is null + ? Array.Empty() + : await _siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false); + targetConnectionsBySiteIdentifier[siteIdentifier] = conns; + return conns; + } + + // ---- Sites ---- + foreach (var siteDto in content.Sites) + { + var existing = await ResolveTargetSiteAsync(siteDto.SiteIdentifier).ConfigureAwait(false); + items.Add(_diff.CompareSite(siteDto, existing)); + } + + // ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ---- + foreach (var dcDto in content.DataConnections) + { + var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false); + var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal)); + items.Add(_diff.CompareDataConnection(dcDto, existing)); + } + + // ---- Instances (hydrated target + resolved template/site/area names; review item I2) ---- + foreach (var instDto in content.Instances) + { + // GetInstanceByUniqueNameAsync eagerly Includes all four child nav + // collections (AttributeOverrides / AlarmOverrides / ConnectionBindings / + // NativeAlarmSourceOverrides), so the entity handed to CompareInstance is + // HYDRATED — its children diff correctly instead of every incoming child + // reading as an addition (review item I2). + var existing = await _templateRepo + .GetInstanceByUniqueNameAsync(instDto.UniqueName, ct).ConfigureAwait(false); + + string? existingTemplateName = null; + string? existingSiteIdentifier = null; + string? existingAreaName = null; + if (existing is not null) + { + // The entity stores template/site/area as numeric FKs that can't be + // compared cross-environment, so resolve each to the same stable + // string identity the incoming DTO carries. + var tmpl = await _templateRepo.GetTemplateByIdAsync(existing.TemplateId, ct).ConfigureAwait(false); + existingTemplateName = tmpl?.Name; + + var site = await _siteRepo.GetSiteByIdAsync(existing.SiteId, ct).ConfigureAwait(false); + existingSiteIdentifier = site?.SiteIdentifier; + + if (existing.AreaId is int areaId) + { + var area = await _templateRepo.GetAreaByIdAsync(areaId, ct).ConfigureAwait(false); + existingAreaName = area?.Name; + } + } + + items.Add(_diff.CompareInstance( + instDto, existing, existingTemplateName, existingSiteIdentifier, existingAreaName)); + } + + // ---- Required site/connection mappings (M8) ---- + var (requiredSites, requiredConnections) = await BuildRequiredMappingsAsync( + content, ResolveTargetSiteAsync, ResolveTargetConnectionsAsync).ConfigureAwait(false); + + // ---- Blocker detection ---- + items.AddRange(await DetectBlockersAsync(content, ResolveTargetConnectionsAsync, ct).ConfigureAwait(false)); + + return new ImportPreview(sessionId, items, requiredSites, requiredConnections); + } + + /// + /// Collects the distinct set of source sites and (site, connection) pairs the + /// bundle references, auto-matching each against the TARGET environment by + /// identity, and returns the operator-facing required-mapping lists (M8 C2). + /// + /// Site references are drawn from every instance, every site, and every + /// data-connection in the bundle. Connection references are drawn from every + /// instance connection-binding, every non-null native-alarm-source + /// ConnectionNameOverride, and every bundled data-connection. A source + /// site auto-matches when the target has a site with the same identifier; a + /// connection auto-matches when that target site additionally carries a + /// connection of the same name. No match leaves AutoMatchTarget* null — + /// the operator must supply an explicit mapping (or accept create-new) at + /// apply time. The diff path above already populated the resolver caches, so + /// these lookups are served from memory. + /// + /// + private static async Task<(IReadOnlyList Sites, IReadOnlyList Connections)> + BuildRequiredMappingsAsync( + BundleContentDto content, + Func> resolveTargetSite, + Func>> resolveTargetConnections) + { + // Distinct source-site identifiers, with a best-effort display name. + // SiteDto carries a Name; instances / data-connections only carry the + // identifier, so default that to the identifier when no SiteDto is present. + var siteNameByIdentifier = new Dictionary(StringComparer.Ordinal); + void NoteSite(string identifier, string? name) + { + if (string.IsNullOrEmpty(identifier)) return; + if (!siteNameByIdentifier.TryGetValue(identifier, out var existing) + || (string.Equals(existing, identifier, StringComparison.Ordinal) && !string.IsNullOrEmpty(name))) + { + siteNameByIdentifier[identifier] = string.IsNullOrEmpty(name) ? identifier : name; + } + } + + foreach (var s in content.Sites) NoteSite(s.SiteIdentifier, s.Name); + foreach (var i in content.Instances) NoteSite(i.SiteIdentifier, null); + foreach (var dc in content.DataConnections) NoteSite(dc.SiteIdentifier, null); + + // Distinct (sourceSite, connectionName) pairs referenced anywhere. + var connectionRefs = new HashSet<(string Site, string Name)>(); + foreach (var i in content.Instances) + { + NoteSite(i.SiteIdentifier, null); + foreach (var b in i.ConnectionBindings) + { + if (!string.IsNullOrEmpty(b.ConnectionName)) + { + connectionRefs.Add((i.SiteIdentifier, b.ConnectionName)); + } + } + foreach (var n in i.NativeAlarmSourceOverrides) + { + if (!string.IsNullOrEmpty(n.ConnectionNameOverride)) + { + connectionRefs.Add((i.SiteIdentifier, n.ConnectionNameOverride)); + } + } + } + foreach (var dc in content.DataConnections) + { + if (!string.IsNullOrEmpty(dc.Name)) + { + connectionRefs.Add((dc.SiteIdentifier, dc.Name)); + } + } + + var siteMappings = new List(); + foreach (var identifier in siteNameByIdentifier.Keys.OrderBy(k => k, StringComparer.Ordinal)) + { + var target = await resolveTargetSite(identifier).ConfigureAwait(false); + siteMappings.Add(new RequiredSiteMapping( + SourceSiteIdentifier: identifier, + SourceSiteName: siteNameByIdentifier[identifier], + AutoMatchTargetIdentifier: target?.SiteIdentifier)); + } + + var connectionMappings = new List(); + foreach (var (site, name) in connectionRefs + .OrderBy(r => r.Site, StringComparer.Ordinal) + .ThenBy(r => r.Name, StringComparer.Ordinal)) + { + // Auto-match only WITHIN the auto-matched target site: a connection of + // the same name under a different site is not a valid match. + var targetConns = await resolveTargetConnections(site).ConfigureAwait(false); + var matched = targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal)); + connectionMappings.Add(new RequiredConnectionMapping( + SourceSiteIdentifier: site, + SourceConnectionName: name, + AutoMatchTargetName: matched ? name : null)); + } + + return (siteMappings, connectionMappings); } /// @@ -442,10 +632,92 @@ public sealed class BundleImporter : IBundleImporter /// the resolver's scan operates on entity Code while the importer's scan /// operates on DTO Code — same algorithm, different inputs. /// - private async Task> DetectBlockersAsync(BundleContentDto content, CancellationToken ct) + private async Task> DetectBlockersAsync( + BundleContentDto content, + Func>> resolveTargetConnections, + CancellationToken ct) { var blockers = new List(); + // ---- M8: instance template + referenced-connection blockers ---- + // The set of template names the import can satisfy = (in-bundle templates) + // ∪ (templates already in the target DB). An instance whose TemplateName is + // in neither is unresolvable. + var bundleTemplateNames = new HashSet(StringComparer.Ordinal); + foreach (var t in content.Templates) bundleTemplateNames.Add(t.Name); + // Honour a rename: a bundled template resolved as Rename is created under + // its new name, but at preview time no resolution has been chosen yet, so + // the in-bundle name is the original DTO name — which is what an instance + // references. (Explicit rename remap is a D-wave apply-time concern.) + var targetTemplateNames = new HashSet(StringComparer.Ordinal); + foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false)) + { + targetTemplateNames.Add(t.Name); + } + + foreach (var inst in content.Instances) + { + if (string.IsNullOrEmpty(inst.TemplateName)) continue; + if (bundleTemplateNames.Contains(inst.TemplateName)) continue; + if (targetTemplateNames.Contains(inst.TemplateName)) continue; + blockers.Add(new ImportPreviewItem( + EntityType: "Instance", + Name: inst.UniqueName, + ExistingVersion: null, + IncomingVersion: null, + Kind: ConflictKind.Blocker, + FieldDiffJson: null, + BlockerReason: $"Template '{inst.TemplateName}' not found in bundle or target.")); + } + + // A referenced (sourceSite, connectionName) pair is resolvable when it is + // either carried in the bundle's DataConnections OR auto-matches a connection + // of the same name in the auto-matched target site. Genuinely-missing + // references are blockers. (Explicit operator connection maps are applied in + // a later wave; preview's auto-match is identity-based.) + var bundleConnections = new HashSet<(string Site, string Name)>(); + foreach (var dc in content.DataConnections) + { + if (!string.IsNullOrEmpty(dc.Name)) bundleConnections.Add((dc.SiteIdentifier, dc.Name)); + } + + // Distinct referenced pairs from instance bindings + native-alarm overrides. + var referencedConnections = new HashSet<(string Site, string Name)>(); + foreach (var inst in content.Instances) + { + foreach (var b in inst.ConnectionBindings) + { + if (!string.IsNullOrEmpty(b.ConnectionName)) + { + referencedConnections.Add((inst.SiteIdentifier, b.ConnectionName)); + } + } + foreach (var n in inst.NativeAlarmSourceOverrides) + { + if (!string.IsNullOrEmpty(n.ConnectionNameOverride)) + { + referencedConnections.Add((inst.SiteIdentifier, n.ConnectionNameOverride)); + } + } + } + + foreach (var (site, name) in referencedConnections + .OrderBy(r => r.Site, StringComparer.Ordinal) + .ThenBy(r => r.Name, StringComparer.Ordinal)) + { + if (bundleConnections.Contains((site, name))) continue; + var targetConns = await resolveTargetConnections(site).ConfigureAwait(false); + if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue; + blockers.Add(new ImportPreviewItem( + EntityType: "Instance", + Name: name, + ExistingVersion: null, + IncomingVersion: null, + Kind: ConflictKind.Blocker, + FieldDiffJson: null, + BlockerReason: $"Connection '{site}/{name}' unresolved — present in neither bundle nor target.")); + } + // Known-resolvable names = (in-bundle) ∪ (already-in-target). var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false); var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs index b79a7611..5b8ec515 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterPreviewTests.cs @@ -2,16 +2,20 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; 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.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; 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.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Serialization; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; @@ -78,6 +82,139 @@ public sealed class BundleImporterPreviewTests : IDisposable return ms.ToArray(); } + /// Exports every seeded site (and its instance/connection closure) into a bundle. + private async Task ExportAllSitesAsync() + { + await using var scope = _provider.CreateAsyncScope(); + var exporter = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync(); + var selection = new ExportSelection( + TemplateIds: Array.Empty(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: true, + SiteIds: siteIds); + + return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + } + + /// + /// Seeds a template + a site + a site-scoped data connection + an instance bound + /// to that connection (with one attribute override) so a full site closure can be + /// exported and previewed. Returns the seeded site identifier + connection name. + /// + private async Task SeedSiteClosureAsync( + string siteIdentifier = "plant-1", + string siteName = "Plant 1", + string connectionName = "OpcUaPrimary", + string templateName = "Pump", + string instanceName = "Pump-01") + { + await using var scope = _provider.CreateAsyncScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var template = new Template(templateName) { Description = "pump tpl" }; + template.Attributes.Add(new TemplateAttribute("Flow") { Value = "0" }); + ctx.Templates.Add(template); + + var site = new Site(siteName, siteIdentifier) { Description = "primary plant" }; + ctx.Sites.Add(site); + await ctx.SaveChangesAsync(); + + var conn = new DataConnection(connectionName, "OpcUa", site.Id) + { + PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}", + FailoverRetryCount = 3, + }; + ctx.DataConnections.Add(conn); + + var instance = new Instance(instanceName) + { + TemplateId = template.Id, + SiteId = site.Id, + State = InstanceState.Enabled, + }; + instance.AttributeOverrides.Add(new InstanceAttributeOverride("Flow") { OverrideValue = "42" }); + ctx.Instances.Add(instance); + await ctx.SaveChangesAsync(); + + instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow") + { + DataConnectionId = conn.Id, + DataSourceReferenceOverride = "ns=3;s=Pump.Flow", + }); + await ctx.SaveChangesAsync(); + } + + /// + /// Packs an arbitrary into a real, loadable plaintext + /// bundle so blocker scenarios can reference entities that the export resolver would + /// never carry (e.g. an instance pointing at a template absent from both bundle and + /// target). Reuses the production manifest builder + serializer for hash fidelity. + /// + private async Task PackBundleAsync(BundleContentDto content) + { + await using var scope = _provider.CreateAsyncScope(); + var manifestBuilder = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + + var summary = new BundleSummary( + Templates: content.Templates.Count, + TemplateFolders: content.TemplateFolders.Count, + SharedScripts: content.SharedScripts.Count, + ExternalSystems: content.ExternalSystems.Count, + DbConnections: content.DatabaseConnections.Count, + NotificationLists: content.NotificationLists.Count, + SmtpConfigs: content.SmtpConfigs.Count, + ApiMethods: content.ApiMethods.Count, + Sites: content.Sites.Count, + DataConnections: content.DataConnections.Count, + Instances: content.Instances.Count); + + var manifest = manifestBuilder.Build( + sourceEnvironment: "dev", + exportedBy: "alice", + scadaBridgeVersion: "1.0.0", + encryption: null, + summary: summary, + contents: Array.Empty(), + contentBytes: serializer.SerializeContentBytes(content)); + + var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null); + return await StreamToBytes(packed); + } + + private static BundleContentDto EmptyContent() => new( + TemplateFolders: Array.Empty(), + Templates: Array.Empty(), + SharedScripts: Array.Empty(), + ExternalSystems: Array.Empty(), + DatabaseConnections: Array.Empty(), + NotificationLists: Array.Empty(), + SmtpConfigs: Array.Empty(), + ApiMethods: Array.Empty()); + + private static InstanceDto SimpleInstanceDto( + string uniqueName, + string templateName, + string siteIdentifier, + IReadOnlyList? bindings = null) => new( + UniqueName: uniqueName, + TemplateName: templateName, + SiteIdentifier: siteIdentifier, + AreaName: null, + State: InstanceState.Enabled, + AttributeOverrides: Array.Empty(), + AlarmOverrides: Array.Empty(), + NativeAlarmSourceOverrides: Array.Empty(), + ConnectionBindings: bindings ?? Array.Empty()); + [Fact] public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match() { @@ -388,4 +525,206 @@ public sealed class BundleImporterPreviewTests : IDisposable Assert.Equal(ConflictKind.Identical, valveItem.Kind); Assert.Equal(ConflictKind.Identical, tankItem.Kind); } + + // ---- M8 C2: site/connection/instance preview + required-mapping detection + blockers ---- + + [Fact] + public async Task PreviewAsync_into_fresh_target_surfaces_required_mappings_with_null_auto_match() + { + // Arrange: seed a full site closure, export it, then wipe the target so the + // site + connection are absent. The preview must surface the site + + // connection as required mappings with no auto-match (create-new implied), + // and classify the site/connection/instance as New. + await SeedSiteClosureAsync(); + var bundleStream = await ExportAllSitesAsync(); + var bytes = await StreamToBytes(bundleStream); + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Instances.RemoveRange(ctx.Instances); + ctx.DataConnections.RemoveRange(ctx.DataConnections); + ctx.Sites.RemoveRange(ctx.Sites); + ctx.Templates.RemoveRange(ctx.Templates); + await ctx.SaveChangesAsync(); + } + + ImportPreview preview; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); + preview = await importer.PreviewAsync(session.SessionId); + } + + var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1"); + Assert.Null(siteMapping.AutoMatchTargetIdentifier); + Assert.Equal("Plant 1", siteMapping.SourceSiteName); + + var connMapping = Assert.Single(preview.RequiredConnectionMappings, + m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary"); + Assert.Null(connMapping.AutoMatchTargetName); + + // The fresh target carries none of these, so each is classified New (and the + // missing template would block — but we wiped the template too, so the + // instance also blocks; assert the New site/connection at minimum). + Assert.Contains(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1" && i.Kind == ConflictKind.New); + Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary" && i.Kind == ConflictKind.New); + } + + [Fact] + public async Task PreviewAsync_into_populated_target_auto_matches_site_and_connection() + { + // Arrange: seed a site closure, export it, leave the target unchanged. The + // bundle's site + connection identity-match the target, so both required + // mappings auto-match and the per-type diffs read Identical. + await SeedSiteClosureAsync(); + var bundleStream = await ExportAllSitesAsync(); + var bytes = await StreamToBytes(bundleStream); + + ImportPreview preview; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); + preview = await importer.PreviewAsync(session.SessionId); + } + + var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1"); + Assert.Equal("plant-1", siteMapping.AutoMatchTargetIdentifier); + + var connMapping = Assert.Single(preview.RequiredConnectionMappings, + m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary"); + Assert.Equal("OpcUaPrimary", connMapping.AutoMatchTargetName); + + // The site + connection match the target exactly → Identical, not New. + var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1"); + Assert.Equal(ConflictKind.Identical, siteItem.Kind); + var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary"); + Assert.Equal(ConflictKind.Identical, connItem.Kind); + + // No blocker — the template + connection both resolve in the target. + Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance"); + } + + [Fact] + public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added() + { + // Arrange: seed a site closure (instance has a Flow=42 attribute override), + // export it, then mutate the TARGET instance's override value. The diff must + // surface a single modified child override — proving CompareInstance received + // a HYDRATED existing instance (I2). A non-hydrated entity would read the + // existing children as empty and report the bundle's override as an addition. + await SeedSiteClosureAsync(); + var bundleStream = await ExportAllSitesAsync(); + var bytes = await StreamToBytes(bundleStream); + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var ovr = await ctx.InstanceAttributeOverrides.SingleAsync(o => o.AttributeName == "Flow"); + ovr.OverrideValue = "99"; // bundle still carries 42 + await ctx.SaveChangesAsync(); + } + + ImportPreview preview; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); + preview = await importer.PreviewAsync(session.SessionId); + } + + var instItem = Assert.Single(preview.Items, i => i.EntityType == "Instance" && i.Name == "Pump-01"); + Assert.Equal(ConflictKind.Modified, instItem.Kind); + Assert.NotNull(instItem.FieldDiffJson); + // The diff names the AttributeOverrides collection (a change, NOT an add of + // every existing child). The hydrated existing instance already had a Flow + // override, so the diff is a modification, not an addition. + Assert.Contains("AttributeOverrides", instItem.FieldDiffJson!, StringComparison.Ordinal); + // A non-hydrated existing would have reported AreaName/State unchanged but + // every override as Added — assert the diff did NOT explode into the other + // unchanged children (ConnectionBindings DataSourceReference matches verbatim). + Assert.DoesNotContain("\"ConnectionBindings\"", instItem.FieldDiffJson!, StringComparison.Ordinal); + } + + [Fact] + public async Task PreviewAsync_instance_with_missing_template_emits_blocker() + { + // Arrange: hand-build a bundle whose instance references a template present in + // NEITHER the bundle nor the target. (The real exporter would never produce + // this — it always carries the instance's template — so we pack directly.) + var content = EmptyContent() with + { + Instances = new[] + { + SimpleInstanceDto("Ghost-01", templateName: "NoSuchTemplate", siteIdentifier: "plant-1"), + }, + }; + var bytes = await PackBundleAsync(content); + + ImportPreview preview; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); + preview = await importer.PreviewAsync(session.SessionId); + } + + Assert.Contains(preview.Items, i => + i.Kind == ConflictKind.Blocker + && i.EntityType == "Instance" + && i.Name == "Ghost-01" + && i.BlockerReason is not null + && i.BlockerReason.Contains("NoSuchTemplate", StringComparison.Ordinal)); + } + + [Fact] + public async Task PreviewAsync_referenced_connection_absent_from_bundle_and_target_emits_blocker() + { + // Arrange: seed the template in the target so the instance's TEMPLATE resolves + // (isolating the connection blocker), then hand-build a bundle whose instance + // binds an attribute to a connection that is in neither the bundle nor the + // target. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.Add(new Template("Pump") { Description = "resolves" }); + await ctx.SaveChangesAsync(); + } + + var content = EmptyContent() with + { + Instances = new[] + { + SimpleInstanceDto("Pump-99", templateName: "Pump", siteIdentifier: "plant-1", + bindings: new[] + { + new InstanceConnectionBindingDto("Flow", "PhantomConn", DataSourceReferenceOverride: null), + }), + }, + }; + var bytes = await PackBundleAsync(content); + + ImportPreview preview; + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null); + preview = await importer.PreviewAsync(session.SessionId); + } + + // The unresolved connection blocks… + Assert.Contains(preview.Items, i => + i.Kind == ConflictKind.Blocker + && i.Name == "PhantomConn" + && i.BlockerReason is not null + && i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal)); + // …but the template resolves in the target, so the instance is NOT a + // missing-template blocker. + Assert.DoesNotContain(preview.Items, i => + i.Kind == ConflictKind.Blocker + && i.EntityType == "Instance" + && i.Name == "Pump-99"); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs index e8d15cb5..0059fb79 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs @@ -110,6 +110,7 @@ public sealed class BundleImporterLoadTests externalRepo: Substitute.For(), notificationRepo: Substitute.For(), inboundApiRepo: Substitute.For(), + siteRepo: Substitute.For(), auditService: Substitute.For(), correlationContext: Substitute.For(), // LoadAsync never touches the DbContext — Preview/Apply do. Build