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; /// /// Integration tests for . /// Reuses the same in-memory host pattern as the exporter tests: real /// repositories, real EF in-memory provider, real Transport pipeline. Each test /// seeds the target DB, exports a bundle, then loads + previews it via the /// importer. /// public sealed class BundleImporterPreviewTests : IDisposable { private readonly ServiceProvider _provider; public BundleImporterPreviewTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts.UseInMemoryDatabase(dbName)); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); // 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.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); private async Task ExportTemplatesAsync() { await using var scope = _provider.CreateAsyncScope(); var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var ids = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: ids, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } private static async Task StreamToBytes(Stream s) { using var ms = new MemoryStream(); await s.CopyToAsync(ms); 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() { // Arrange: seed a template, export it, leave target unchanged. The // bundle's DTO is the literal projection of the target, so the diff // should classify it as Identical. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "stable" }); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Act 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 var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); Assert.Equal(ConflictKind.Identical, pumpItem.Kind); Assert.Null(pumpItem.FieldDiffJson); } [Fact] public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff() { // Arrange: seed a template with Description="new", export it, then // overwrite the target template's Description with "old". The bundle's // version differs from the target, so the diff should flag the // Description field. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "new" }); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Mutate the target between export and preview so the diff has // something to report. The bundle still carries Description="new". await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); t.Description = "old"; await ctx.SaveChangesAsync(); } // Act 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 var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); Assert.Equal(ConflictKind.Modified, pumpItem.Kind); Assert.NotNull(pumpItem.FieldDiffJson); // The diff should mention the Description field by name. Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal); } [Fact] public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target() { // Arrange: seed a template, export it, then delete it from the target // database. The bundle still contains the template, so the diff should // classify it as New (target is now empty). await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "to-be-deleted" }); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.SingleAsync(); ctx.Templates.Remove(t); await ctx.SaveChangesAsync(); } // Act 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 var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); Assert.Equal(ConflictKind.New, pumpItem.Kind); Assert.Null(pumpItem.FieldDiffJson); } [Fact] public async Task PreviewAsync_emits_Blocker_when_required_dependency_missing() { // Arrange: seed a template whose script body calls MissingHelper(), and // an unrelated HelperFn() shared script that *is* defined but isn't the // referenced one. We then export WITHOUT IncludeDependencies and use a // selection that only pulls the template — the bundle won't carry // MissingHelper (it doesn't exist anywhere) so the preview must flag it. // // To get MissingHelper into the bundle script body without the export // resolver pulling it in (it can't — it doesn't exist), we just seed // the template with a script that mentions it; the resolver scan only // matters for entity discovery, the body text is preserved verbatim. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey")); var t = new Template("Pump") { Description = "broken" }; t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Wipe the SharedScripts table so MissingHelper has no chance of being // resolved in the target either. (HelperFn is intentionally seeded so // we can verify the blocker check is specific — it should NOT flag // HelperFn since it's in the target.) await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); // Keep HelperFn + ErpSystem so they're in the target's resolved set. // Just confirm via assertion that MissingHelper is the blocker name. await ctx.SaveChangesAsync(); } // Act 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: there's at least one Blocker, and the MissingHelper one is in there. Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker); Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == "MissingHelper" && i.BlockerReason is not null && i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal)); // Conversely, HelperFn must NOT be a blocker — it's seeded in the target. Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == "HelperFn"); } [Fact] public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers() { // Arrange: a template with an attribute whose DataSourceReference is an // OPC UA node-address path -- e.g. "ns=3;s=Tank.Level". The segment // before the dot ("Tank") used to be parsed by the blocker heuristic as // a potential SharedScript reference, even though tag paths live in the // device's address space and are not script-callable. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "tag-path-check" }; t.Attributes.Add(new TemplateAttribute("Level") { Value = "0", DataSourceReference = "ns=3;s=Tank.Level", }); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Act 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: "Tank" (the device-owned tag-path root segment) must not be // flagged as a missing SharedScript or ExternalSystem reference. Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == "Tank"); } [Fact] public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers() { // Arrange: a template script that uses a representative mix of stdlib // calls, runtime-API roots, and member-access patterns. None of these // are user-defined SharedScripts or ExternalSystems and the previous // heuristic was flagging every one of them. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "noise-check" }; t.Scripts.Add(new TemplateScript("init", """ var now = DateTimeOffset.UtcNow; var s = Convert.ToString(123); await Notify.Send("alerts", "msg"); var x = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM t"); var y = await ExternalSystem.Call("erp", "ping"); obj.Dispose(); """)); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); var bytes = await StreamToBytes(bundleStream); // Act 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: none of the well-known names produce blocker rows. string[] noiseNames = { "DateTimeOffset", "UtcNow", "Convert", "ToString", "Notify", "Send", "Database", "ExecuteScalarAsync", "COUNT", "ExternalSystem", "Call", "Dispose", }; foreach (var name in noiseNames) { Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == name); } } [Fact] public async Task PreviewAsync_multiple_templates_with_children_diffs_each_correctly() { // Transport-008 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 // the preview hydrates each one so the per-child diff sees the right // attribute / alarm / script counts (i.e. the bulk fetch did not lose // any child rows compared to the per-name fetch). await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var pump = new Template("Pump") { Description = "p1" }; pump.Attributes.Add(new TemplateAttribute("Flow")); pump.Scripts.Add(new TemplateScript("init", "return 1;")); var valve = new Template("Valve") { Description = "v1" }; valve.Alarms.Add(new TemplateAlarm("HighPressure")); var tank = new Template("Tank") { Description = "t1" }; tank.Attributes.Add(new TemplateAttribute("Level")); tank.Attributes.Add(new TemplateAttribute("Temperature")); ctx.Templates.AddRange(pump, valve, tank); await ctx.SaveChangesAsync(); } var bundleStream = await ExportTemplatesAsync(); 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); } // Each template should be diff-classified (Identical, since the bundle // is the literal projection of the target). Critically, the diff must // succeed for ALL three — a bulk-fetch bug that silently drops rows // would surface here as a missing item or a wrong (New) classification. var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump"); var valveItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Valve"); var tankItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Tank"); Assert.Equal(ConflictKind.Identical, pumpItem.Kind); 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); // C2: DataConnection preview items are site-qualified ({site}/{name}). Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/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); // C2: DataConnection preview items are site-qualified ({site}/{name}). var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/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_two_sites_same_connection_name_emit_two_distinct_site_qualified_items() { // C2: a bundle with plant-1/OpcUaPrimary + plant-2/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 // independently. Hand-pack the bundle so both same-named connections are // present without a heavy two-site export. 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), new SiteDto("plant-2", "Plant 2", null, null, null, null, null), }, DataConnections = new[] { new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null), new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 3, null), }, Instances = Array.Empty(), }; 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); } var connItems = preview.Items .Where(i => i.EntityType == "DataConnection") .Select(i => i.Name) .OrderBy(n => n, StringComparer.Ordinal) .ToList(); // Two distinct site-qualified items — NOT one bare "OpcUaPrimary". Assert.Equal(new[] { "plant-1/OpcUaPrimary", "plant-2/OpcUaPrimary" }, connItems); Assert.DoesNotContain(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary"); } [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… (C2: blocker Name is site-qualified // {site}/{name} so two sites' same-named missing connections don't collide). Assert.Contains(preview.Items, i => i.Kind == ConflictKind.Blocker && i.Name == "plant-1/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"); } }