987 lines
50 KiB
C#
987 lines
50 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
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.Import;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
|
|
|
/// <summary>
|
|
/// Integration tests for the M8 D1 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
|
|
/// <c>DataConnectionId</c>, native-alarm-source <c>ConnectionNameOverride</c>) onto
|
|
/// the target's surrogate keys.
|
|
/// <para>
|
|
/// Reuses the in-memory host pattern from <c>BundleImporterApplyTests</c> /
|
|
/// <c>BundleImporterPreviewTests</c>: real repositories, real EF in-memory provider,
|
|
/// real Transport pipeline. Bundles are produced by the real exporter (site closure)
|
|
/// or hand-packed via <see cref="BundleSerializer"/> for negative cases.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SiteInstanceImportTests : IDisposable
|
|
{
|
|
private readonly ServiceProvider _provider;
|
|
|
|
public SiteInstanceImportTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(
|
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
|
|
|
var dbName = $"SiteInstanceImportTests_{Guid.NewGuid()}";
|
|
// Same in-memory caveat as BundleImporterApplyTests: ApplyAsync opens a
|
|
// transaction (no-op on in-memory) and defers the single real
|
|
// SaveChangesAsync to just before CommitAsync; intermediate flushes are
|
|
// undone on the catch path via ChangeTracker.Clear(). Downgrade the
|
|
// transaction-ignored warning so the in-memory run proceeds.
|
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
|
.UseInMemoryDatabase(dbName)
|
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
|
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddTransport();
|
|
|
|
_provider = services.BuildServiceProvider();
|
|
}
|
|
|
|
public void Dispose() => _provider.Dispose();
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Seeds a full site closure (template + site + site-scoped data connection +
|
|
/// instance bound to that connection, with an attribute override, an alarm
|
|
/// override and a native-alarm-source override whose ConnectionNameOverride
|
|
/// names the seeded connection) so the export carries every FK shape D1 must
|
|
/// rewire. The instance is seeded <see cref="InstanceState.Enabled"/> so the
|
|
/// import's NotDeployed reset is observable.
|
|
/// </summary>
|
|
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<ScadaBridgeDbContext>();
|
|
|
|
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",
|
|
NodeAAddress = "akka://site@10.0.0.1:2552",
|
|
NodeBAddress = "akka://site@10.0.0.2:2552",
|
|
GrpcNodeAAddress = "10.0.0.1:8083",
|
|
GrpcNodeBAddress = "10.0.0.2:8083",
|
|
};
|
|
ctx.Sites.Add(site);
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var conn = new DataConnection(connectionName, "OpcUa", site.Id)
|
|
{
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
|
|
BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}",
|
|
FailoverRetryCount = 5,
|
|
};
|
|
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" });
|
|
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm") { PriorityLevelOverride = 7 });
|
|
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
|
|
{
|
|
ConnectionNameOverride = connectionName,
|
|
SourceReferenceOverride = "ns=3;s=Pump.Alarm",
|
|
});
|
|
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();
|
|
}
|
|
|
|
/// <summary>Exports every seeded site (and its instance/connection closure) into a bundle, then loads it.</summary>
|
|
private async Task<Guid> ExportAllSitesAndLoadAsync()
|
|
{
|
|
Stream bundleStream;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: Array.Empty<int>(),
|
|
SharedScriptIds: Array.Empty<int>(),
|
|
ExternalSystemIds: Array.Empty<int>(),
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: Array.Empty<int>(),
|
|
IncludeDependencies: true,
|
|
SiteIds: siteIds);
|
|
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
}
|
|
|
|
using var ms = new MemoryStream();
|
|
await bundleStream.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
|
|
await using var loadScope = _provider.CreateAsyncScope();
|
|
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
return session.SessionId;
|
|
}
|
|
|
|
/// <summary>Removes all site/instance-scoped rows so the import exercises the CreateNew path against a fresh target.</summary>
|
|
private async Task WipeSiteClosureAsync()
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.InstanceConnectionBindings.RemoveRange(ctx.InstanceConnectionBindings);
|
|
ctx.InstanceAttributeOverrides.RemoveRange(ctx.InstanceAttributeOverrides);
|
|
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
|
|
ctx.InstanceNativeAlarmSourceOverrides.RemoveRange(ctx.InstanceNativeAlarmSourceOverrides);
|
|
ctx.Instances.RemoveRange(ctx.Instances);
|
|
ctx.DataConnections.RemoveRange(ctx.DataConnections);
|
|
ctx.Areas.RemoveRange(ctx.Areas);
|
|
ctx.Sites.RemoveRange(ctx.Sites);
|
|
ctx.Templates.RemoveRange(ctx.Templates);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task<ImportResult> ApplyAsync(
|
|
Guid sessionId, IReadOnlyList<ImportResolution> resolutions, BundleNameMap nameMap)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
return await importer.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None, nameMap: nameMap);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hand-packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable
|
|
/// plaintext bundle and opens a session. Lets negative-path tests carry an
|
|
/// instance whose TemplateName the export resolver would never emit (e.g. a
|
|
/// template absent from both bundle and target), so the instance pass' guard
|
|
/// can be exercised mid-transaction. Reuses the production manifest builder +
|
|
/// serializer for hash fidelity.
|
|
/// </summary>
|
|
private async Task<Guid> PackAndLoadAsync(BundleContentDto content)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
|
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
|
|
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<ManifestContentEntry>(),
|
|
contentBytes: serializer.SerializeContentBytes(content));
|
|
|
|
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
|
|
using var ms = new MemoryStream();
|
|
await packed.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
return session.SessionId;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// CreateNew into a fresh target
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_CreateNew_into_fresh_target_creates_site_connection_instance_with_rewired_FKs()
|
|
{
|
|
await SeedSiteClosureAsync();
|
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
|
await WipeSiteClosureAsync();
|
|
|
|
// The template must exist for the instance to resolve its TemplateId.
|
|
// The wipe removed it, so re-create it (a real import that carries the
|
|
// template would Add it; here we isolate the site/instance path).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
|
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
|
|
|
|
var result = await ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Skip, null), // already present
|
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
|
// C2: DataConnection resolutions are keyed by the site-qualified name.
|
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
|
},
|
|
nameMap);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
|
|
var site = await ctx.Sites.SingleAsync(s => s.SiteIdentifier == "plant-1");
|
|
Assert.Equal("Plant 1", site.Name);
|
|
// Full config carried (D3 "carry full config").
|
|
Assert.Equal("10.0.0.1:8083", site.GrpcNodeAAddress);
|
|
Assert.Equal("akka://site@10.0.0.2:2552", site.NodeBAddress);
|
|
|
|
var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
|
|
Assert.Equal(site.Id, conn.SiteId);
|
|
// Primary/Backup config restored from the bundle's secrets block.
|
|
Assert.Contains("opc.tcp://primary", conn.PrimaryConfiguration!);
|
|
Assert.Contains("opc.tcp://backup", conn.BackupConfiguration!);
|
|
Assert.Equal(5, conn.FailoverRetryCount);
|
|
|
|
var inst = await ctx.Instances
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.SingleAsync(i => i.UniqueName == "Pump-01");
|
|
|
|
// Imported instances are design-time config — never carried as deployed.
|
|
Assert.Equal(InstanceState.NotDeployed, inst.State);
|
|
Assert.Equal(site.Id, inst.SiteId);
|
|
|
|
// FK rewire — binding points at the CREATED connection's surrogate id.
|
|
var binding = Assert.Single(inst.ConnectionBindings);
|
|
Assert.Equal(conn.Id, binding.DataConnectionId);
|
|
Assert.Equal("ns=3;s=Pump.Flow", binding.DataSourceReferenceOverride);
|
|
|
|
// Native-alarm override connection-name rewritten to the target name.
|
|
var native = Assert.Single(inst.NativeAlarmSourceOverrides);
|
|
Assert.Equal("OpcUaPrimary", native.ConnectionNameOverride);
|
|
|
|
// Override rows present.
|
|
Assert.Single(inst.AttributeOverrides);
|
|
Assert.Equal("42", inst.AttributeOverrides.Single().OverrideValue);
|
|
Assert.Single(inst.AlarmOverrides);
|
|
Assert.Equal(7, inst.AlarmOverrides.Single().PriorityLevelOverride);
|
|
}
|
|
|
|
// 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.
|
|
Assert.Empty(result.StaleInstanceIds);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// MapToExisting into a populated target (FK remap to existing ids)
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[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
|
|
// 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();
|
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
|
await WipeSiteClosureAsync();
|
|
|
|
// Re-seed the target with the SAME identifiers but fresh rows (no instance
|
|
// — the import brings it). Pad with throwaway rows first so the target's
|
|
// surrogate ids do not coincide with the source's by accident.
|
|
int targetConnId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
// Throwaway site/connection to advance the id counters.
|
|
var pad = new Site("Pad", "pad-site");
|
|
ctx.Sites.Add(pad);
|
|
await ctx.SaveChangesAsync();
|
|
ctx.DataConnections.Add(new DataConnection("PadConn", "OpcUa", pad.Id));
|
|
await ctx.SaveChangesAsync();
|
|
|
|
ctx.Templates.Add(new Template("Pump") { Description = "target pump" });
|
|
var site = new Site("Plant 1 (target)", "plant-1");
|
|
ctx.Sites.Add(site);
|
|
await ctx.SaveChangesAsync();
|
|
var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
|
{
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}",
|
|
};
|
|
ctx.DataConnections.Add(conn);
|
|
await ctx.SaveChangesAsync();
|
|
targetConnId = conn.Id;
|
|
}
|
|
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
|
|
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
|
|
|
|
var result = await ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
|
new("Site", "plant-1", ResolutionAction.Skip, null), // leave target site untouched
|
|
// C2: DataConnection resolutions are keyed by the site-qualified name.
|
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
|
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
|
},
|
|
nameMap);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
|
|
// Skip left the existing target connection's config untouched — NOT a
|
|
// new connection created from the bundle.
|
|
var conns = await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync();
|
|
var conn = Assert.Single(conns);
|
|
Assert.Equal(targetConnId, conn.Id);
|
|
Assert.Contains("target-existing", conn.PrimaryConfiguration!);
|
|
|
|
var inst = await ctx.Instances
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.SingleAsync(i => i.UniqueName == "Pump-01");
|
|
|
|
// FK remapped explicitly to the EXISTING target connection id.
|
|
var binding = Assert.Single(inst.ConnectionBindings);
|
|
Assert.Equal(targetConnId, binding.DataConnectionId);
|
|
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
|
|
}
|
|
|
|
// Site + connection Skipped, instance Added.
|
|
Assert.Equal(1, result.Added);
|
|
Assert.Equal(3, result.Skipped); // template + site + connection
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Rename an instance on import
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_renames_instance_unique_name_on_import()
|
|
{
|
|
await SeedSiteClosureAsync();
|
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
|
await WipeSiteClosureAsync();
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
|
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
|
|
|
|
var result = await ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Rename, "Pump-01-imported"),
|
|
},
|
|
nameMap);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Equal(0, await ctx.Instances.CountAsync(i => i.UniqueName == "Pump-01"));
|
|
var renamed = await ctx.Instances
|
|
.Include(i => i.ConnectionBindings)
|
|
.SingleAsync(i => i.UniqueName == "Pump-01-imported");
|
|
// Renamed instance still gets its FKs rewired + state reset.
|
|
Assert.Equal(InstanceState.NotDeployed, renamed.State);
|
|
var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
|
|
Assert.Equal(conn.Id, renamed.ConnectionBindings.Single().DataConnectionId);
|
|
}
|
|
|
|
Assert.Equal(1, result.Renamed);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Cross-site rebind: source site maps to a DIFFERENTLY-named target site
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_maps_source_site_to_differently_named_target_site_and_rebinds_connections()
|
|
{
|
|
await SeedSiteClosureAsync(siteIdentifier: "plant-1", connectionName: "OpcUaPrimary");
|
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
|
await WipeSiteClosureAsync();
|
|
|
|
// Target env has a DIFFERENTLY-identified site (plant-west) carrying a
|
|
// same-named connection. The operator maps plant-1 → plant-west.
|
|
int westSiteId;
|
|
int westConnId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
|
var west = new Site("Plant West", "plant-west");
|
|
ctx.Sites.Add(west);
|
|
await ctx.SaveChangesAsync();
|
|
westSiteId = west.Id;
|
|
var conn = new DataConnection("OpcUaPrimary", "OpcUa", west.Id)
|
|
{
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://west\"}",
|
|
};
|
|
ctx.DataConnections.Add(conn);
|
|
await ctx.SaveChangesAsync();
|
|
westConnId = conn.Id;
|
|
}
|
|
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-west") },
|
|
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
|
|
|
|
var result = await ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
|
// C2: DataConnection resolution is keyed by the SOURCE site-qualified name.
|
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
|
},
|
|
nameMap);
|
|
|
|
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.
|
|
Assert.Equal(0, await ctx.Sites.CountAsync(s => s.SiteIdentifier == "plant-1"));
|
|
|
|
var inst = await ctx.Instances
|
|
.Include(i => i.ConnectionBindings)
|
|
.SingleAsync(i => i.UniqueName == "Pump-01");
|
|
// Instance rebound to the TARGET site + its connection id.
|
|
Assert.Equal(westSiteId, inst.SiteId);
|
|
Assert.Equal(westConnId, inst.ConnectionBindings.Single().DataConnectionId);
|
|
}
|
|
|
|
Assert.Equal(1, result.Added);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Rollback: a mid-apply failure persists NOTHING
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_rolls_back_site_and_connection_when_instance_pass_throws()
|
|
{
|
|
// Hand-pack a bundle whose instance references template "GhostTemplate"
|
|
// that exists in NEITHER the bundle NOR the (empty) target. The reference
|
|
// is rejected in the pre-write validation phase (so the change tracker is
|
|
// still empty), the import aborts, and the transaction rolls back — no
|
|
// site, connection, or instance row may survive, and a BundleImportFailed
|
|
// audit row records the abort. The validation-phase rejection surfaces as
|
|
// a SemanticValidationException, the same all-or-nothing failure contract
|
|
// the script-reference and template-validation checks already use.
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>())
|
|
{
|
|
Sites = new[]
|
|
{
|
|
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
|
|
},
|
|
DataConnections = new[]
|
|
{
|
|
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null),
|
|
},
|
|
Instances = new[]
|
|
{
|
|
new InstanceDto(
|
|
UniqueName: "Pump-01",
|
|
TemplateName: "GhostTemplate",
|
|
SiteIdentifier: "plant-1",
|
|
AreaName: null,
|
|
State: InstanceState.Enabled,
|
|
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
|
|
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
|
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
|
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>()),
|
|
},
|
|
};
|
|
var sessionId = await PackAndLoadAsync(content);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
|
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
|
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob",
|
|
ct: CancellationToken.None,
|
|
nameMap: nameMap));
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Equal(0, await ctx.Sites.CountAsync());
|
|
Assert.Equal(0, await ctx.DataConnections.CountAsync());
|
|
Assert.Equal(0, await ctx.Instances.CountAsync());
|
|
// A BundleImportFailed audit row records the aborted import.
|
|
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Overwrite an existing instance: child rows replaced from the bundle
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding()
|
|
{
|
|
await SeedSiteClosureAsync();
|
|
var sessionId = await ExportAllSitesAndLoadAsync();
|
|
|
|
// Mutate the target instance so its children diverge from the bundle:
|
|
// drop the override + binding, add a junk override. Overwrite must restore
|
|
// the bundle's shape and rebind to the (still-present) target connection.
|
|
int targetConnId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
targetConnId = (await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary")).Id;
|
|
var inst = await ctx.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.SingleAsync(i => i.UniqueName == "Pump-01");
|
|
ctx.InstanceAttributeOverrides.RemoveRange(inst.AttributeOverrides);
|
|
ctx.InstanceConnectionBindings.RemoveRange(inst.ConnectionBindings);
|
|
inst.AttributeOverrides.Clear();
|
|
inst.ConnectionBindings.Clear();
|
|
inst.AttributeOverrides.Add(new InstanceAttributeOverride("Junk") { OverrideValue = "stale" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
|
|
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
|
|
|
|
var result = await ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
|
// C2: DataConnection resolution is keyed by the site-qualified name.
|
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Overwrite, null),
|
|
},
|
|
nameMap);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var inst = await ctx.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.SingleAsync(i => i.UniqueName == "Pump-01");
|
|
|
|
// Bundle's child shape restored — junk override gone, Flow override + binding back.
|
|
Assert.DoesNotContain(inst.AttributeOverrides, o => o.AttributeName == "Junk");
|
|
Assert.Contains(inst.AttributeOverrides, o => o.AttributeName == "Flow" && o.OverrideValue == "42");
|
|
var binding = Assert.Single(inst.ConnectionBindings);
|
|
Assert.Equal(targetConnId, binding.DataConnectionId);
|
|
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
|
|
Assert.Equal(InstanceState.NotDeployed, inst.State);
|
|
}
|
|
|
|
Assert.Equal(1, result.Overwritten);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// C1 — binding references a TARGET connection the bundle did NOT carry
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[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
|
|
// 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
|
|
// correctly. Hand-pack the bundle so DataConnections is empty while the
|
|
// instance still references the connection by name.
|
|
int targetConnId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
|
var site = new Site("Plant 1", "plant-1");
|
|
ctx.Sites.Add(site);
|
|
await ctx.SaveChangesAsync();
|
|
var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
|
{
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}",
|
|
};
|
|
ctx.DataConnections.Add(conn);
|
|
await ctx.SaveChangesAsync();
|
|
targetConnId = conn.Id;
|
|
}
|
|
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>())
|
|
{
|
|
// Site carried, but the connection is DELIBERATELY OMITTED from the bundle.
|
|
Sites = new[]
|
|
{
|
|
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
|
|
},
|
|
DataConnections = Array.Empty<DataConnectionDto>(),
|
|
Instances = new[]
|
|
{
|
|
new InstanceDto(
|
|
UniqueName: "Pump-01",
|
|
TemplateName: "Pump",
|
|
SiteIdentifier: "plant-1",
|
|
AreaName: null,
|
|
State: InstanceState.Enabled,
|
|
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
|
|
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
|
NativeAlarmSourceOverrides: new[]
|
|
{
|
|
new InstanceNativeAlarmSourceOverrideDto(
|
|
SourceCanonicalName: "NativeSrc",
|
|
ConnectionNameOverride: "OpcUaPrimary",
|
|
SourceReferenceOverride: "ns=3;s=Pump.Alarm",
|
|
ConditionFilterOverride: null),
|
|
},
|
|
ConnectionBindings: new[]
|
|
{
|
|
new InstanceConnectionBindingDto(
|
|
AttributeName: "Flow",
|
|
ConnectionName: "OpcUaPrimary",
|
|
DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
|
|
}),
|
|
},
|
|
};
|
|
var sessionId = await PackAndLoadAsync(content);
|
|
|
|
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).
|
|
Connections: Array.Empty<ConnectionMapping>());
|
|
|
|
var result = await ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
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<ScadaBridgeDbContext>();
|
|
|
|
// No new connection was created — the omitted-from-bundle connection
|
|
// auto-matched the EXISTING target row.
|
|
var conn = Assert.Single(await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync());
|
|
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: binding FK points at the EXISTING target connection id — NOT 0.
|
|
var binding = Assert.Single(inst.ConnectionBindings);
|
|
Assert.NotEqual(0, binding.DataConnectionId);
|
|
Assert.Equal(targetConnId, binding.DataConnectionId);
|
|
|
|
// Native-alarm-source override connection name carries through.
|
|
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
|
|
}
|
|
|
|
Assert.Equal(1, result.Added);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_binding_to_connection_in_neither_bundle_nor_target_fails_instead_of_writing_zero()
|
|
{
|
|
// C1 guard: a binding naming a connection present in NEITHER the bundle nor
|
|
// the target must FAIL the import (caught in the pre-write validation phase
|
|
// as a SemanticValidationException) rather than silently write
|
|
// DataConnectionId = 0. Nothing may persist.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
|
ctx.Sites.Add(new Site("Plant 1", "plant-1"));
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>())
|
|
{
|
|
DataConnections = Array.Empty<DataConnectionDto>(),
|
|
Instances = new[]
|
|
{
|
|
new InstanceDto(
|
|
UniqueName: "Pump-01",
|
|
TemplateName: "Pump",
|
|
SiteIdentifier: "plant-1",
|
|
AreaName: null,
|
|
State: InstanceState.Enabled,
|
|
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
|
|
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
|
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
|
ConnectionBindings: new[]
|
|
{
|
|
new InstanceConnectionBindingDto(
|
|
AttributeName: "Flow",
|
|
ConnectionName: "GhostConn",
|
|
DataSourceReferenceOverride: null),
|
|
}),
|
|
},
|
|
};
|
|
var sessionId = await PackAndLoadAsync(content);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
|
|
Connections: Array.Empty<ConnectionMapping>());
|
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob",
|
|
ct: CancellationToken.None,
|
|
nameMap: nameMap));
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
// No instance with a zero (or any) binding persisted.
|
|
Assert.Equal(0, await ctx.Instances.CountAsync());
|
|
Assert.Equal(0, await ctx.InstanceConnectionBindings.CountAsync());
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// C2 — two sites with same-named connections resolve independently
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
[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
|
|
// 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.
|
|
int plant1ConnId, plant2ConnId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var s1 = new Site("Plant 1", "plant-1");
|
|
var s2 = new Site("Plant 2", "plant-2");
|
|
ctx.Sites.AddRange(s1, s2);
|
|
await ctx.SaveChangesAsync();
|
|
var c1 = new DataConnection("OpcUaPrimary", "OpcUa", s1.Id)
|
|
{
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p1-existing\"}",
|
|
FailoverRetryCount = 1,
|
|
};
|
|
var c2 = new DataConnection("OpcUaPrimary", "OpcUa", s2.Id)
|
|
{
|
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p2-existing\"}",
|
|
FailoverRetryCount = 2,
|
|
};
|
|
ctx.DataConnections.AddRange(c1, c2);
|
|
await ctx.SaveChangesAsync();
|
|
plant1ConnId = c1.Id;
|
|
plant2ConnId = c2.Id;
|
|
}
|
|
|
|
// Hand-pack a bundle carrying both same-named connections, each with a
|
|
// DISTINCT incoming protocol-config so an Overwrite is observable.
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>())
|
|
{
|
|
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", 9,
|
|
new SecretsBlock(new Dictionary<string, string>
|
|
{
|
|
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p1-from-bundle\"}",
|
|
})),
|
|
new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 9,
|
|
new SecretsBlock(new Dictionary<string, string>
|
|
{
|
|
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p2-from-bundle\"}",
|
|
})),
|
|
},
|
|
Instances = Array.Empty<InstanceDto>(),
|
|
};
|
|
var sessionId = await PackAndLoadAsync(content);
|
|
|
|
var nameMap = new BundleNameMap(
|
|
Sites: new[]
|
|
{
|
|
new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1"),
|
|
new SiteMapping("plant-2", MappingAction.MapToExisting, "plant-2"),
|
|
},
|
|
Connections: new[]
|
|
{
|
|
new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
|
|
new ConnectionMapping("plant-2", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
|
|
});
|
|
|
|
var result = await ApplyAsync(
|
|
sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
|
new("Site", "plant-2", ResolutionAction.Skip, null),
|
|
// Per-site resolutions — keyed by the site-qualified name (C2).
|
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Overwrite, null),
|
|
new("DataConnection", "plant-2/OpcUaPrimary", ResolutionAction.Skip, null),
|
|
},
|
|
nameMap);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
|
|
// 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.
|
|
var c2 = await ctx.DataConnections.SingleAsync(c => c.Id == plant2ConnId);
|
|
Assert.Contains("p2-existing", c2.PrimaryConfiguration!);
|
|
Assert.Equal(2, c2.FailoverRetryCount);
|
|
|
|
// Still exactly two connections — no duplicates created.
|
|
Assert.Equal(2, await ctx.DataConnections.CountAsync(c => c.Name == "OpcUaPrimary"));
|
|
}
|
|
|
|
// One Overwrite (plant-1 conn), three Skips (two sites + plant-2 conn).
|
|
Assert.Equal(1, result.Overwritten);
|
|
Assert.Equal(3, result.Skipped);
|
|
}
|
|
}
|