feat(transport): apply site/instance import with name-map + FK rewire (M8 D1, T18)
This commit is contained in:
+669
@@ -0,0 +1,669 @@
|
||||
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),
|
||||
new("DataConnection", "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
|
||||
new("DataConnection", "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),
|
||||
new("DataConnection", "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", "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),
|
||||
new("DataConnection", "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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user